Response pattern: backend to frontend
All HTTP responses use a unified envelope so backend and frontend share the same contract. Align with NestJS exception filters: the built-in filter returns { statusCode, message }; when you pass an object to the exception constructor, Nest serializes it as the response body. Use statusCode and message as the canonical keys so the envelope works with exception.getResponse() / exception.getStatus() in a custom filter.
Unified envelope (contract)
- Common:
statusCode: number, optionalmessage?: string. - Success (2xx): required
data: T, optionalmessage. - Client error (4xx): optional
message, optionalerror?: string(Nest-style short description), optionalerrors?: string[], optionalfieldErrors?: Record<string, string[]>(per-field, e.g. from zod). - Server error (5xx): optional
message, optionalerror?: string(noerrorsorfieldErrors).
Success shape: { statusCode: number; data: T; message?: string }.
Client error shape: { statusCode: number; message?: string; error?: string; errors?: string[]; fieldErrors?: Record<string, string[]> }.
Server error shape: { statusCode: number; message?: string; error?: string }.
Shared package: Expose zod schema fieldErrorsSchema and TypeScript types for the full union (SuccessResponse<T>, ClientErrorResponse, ServerErrorResponse, ApiResponse<T>) and the client-only union ClientApiResponse<T> = SuccessResponse<T> | ClientErrorResponse. Each variant includes a discriminator type: 'success' | 'client-error' | 'server-error' so callers can branch on response.type to narrow. See pattern sharing for what belongs in the shared package; see validation (zod) and static-typing.
Backend: enforce envelope on every response
Success path: Use a NestJS response interceptor that takes the handler return value (e.g. raw data) and wraps it as { statusCode, data }. Controllers return domain data; they do not manually wrap. See controller-layer.
Error path: Use global exception filters registered via APP_FILTER (e.g. in AppModule). Register a catch-all filter plus specific filters for HttpException and ZodError; Nest runs filters in reverse registration order, so the specific filters run first and the catch-all handles the rest.
ZodExceptionFilter(@Catch(ZodError)): thrown byZodValidationPipeon validation failure. Map to the client-error envelope: buildfieldErrors: Record<string, string[]>from the Zod error (e.g. Zod’sflattenError/ fieldErrors). Respond with{ statusCode: 400, message: 'Validation failed', errors?, fieldErrors }.HttpExceptionFilter(@Catch(HttpException)): get status viaexception.getStatus()and body viaexception.getResponse(); normalize the body to the unified envelope.AllExceptionsFilter(@Catch()): catch-all for any unhandled exception. Log and respond with{ statusCode: 500, message: 'Internal server error' }.
See requests for how the pipe throws the Zod error and binding.
Frontend: handleRequest and union type
handleRequest<T>: The app does not call axios directly. Use handleRequest<T> (e.g. in frontends/shared) which uses the same axios instance (base URL, auth interceptors). On 2xx, normalize response.data to the envelope ({ statusCode, data }). On 4xx/5xx, do not throw; build a result object from error.response?.data and error.response?.status in the same envelope shape and return it. The return type of handleRequest<T> is always the envelope (generic over success data). See http-client and capability auth.
Union type (discriminated): Model the response in shared with a type discriminator on each variant:
type ApiResponse<T> = SuccessResponse<T> | ClientErrorResponse | ServerErrorResponse
type ClientApiResponse<T> = SuccessResponse<T> | ClientErrorResponse // client return type; server errors throw
- SuccessResponse<T> —
type: 'success';statusCode; requireddata: T; optionalmessage. Narrow withresponse.type === 'success'. - ClientErrorResponse —
type: 'client-error'; optionalmessage,error,errors,fieldErrors. Narrow withresponse.type === 'client-error'. - ServerErrorResponse —
type: 'server-error'; optionalmessage,error. Narrow withresponse.type === 'server-error'.
Define all types in shared; handleRequest<T> returns Promise<ClientApiResponse<T>> (success or client-error only; 5xx throws).
Service layer and throw on server error: handleRequest returns ClientApiResponse (success or client-error) on 2xx/4xx and throws ServerError (from shared/api-response) on 5xx. Frontend services call handleRequest; client errors (4xx) are returned; server errors (5xx) throw. The generic Form component uses the service as mutationFn, maps thrown ServerError to ServerErrorResponse inside useMutation, and passes: onSuccess(result: SuccessResponse<TOutput>) when result.type === 'success', and onError(response: ServerErrorResponse) when the mutation throws. Callers can rely on typed result.data in onSuccess and on response.message / response.error in onError. See forms.
Pagination
List endpoints may extend the envelope with data: { items, meta } or similar, still via handleRequest<T> and the same types.
Implementation
Shared: shared/src/api-response.ts defines the contract: a Zod schema fieldErrorsSchema for client-error field errors; TypeScript types SuccessResponse<T>, ClientErrorResponse, ServerErrorResponse, ApiResponse<T> (full union), and ClientApiResponse<T> (success | client-error); each variant has type: 'success' | 'client-error' | 'server-error' for narrowing. The ServerError class (extends Error, carries response: ServerErrorResponse) is thrown by the client on 5xx. The package exports them via the subpath shared/api-response (see monorepo).
Backend: Success responses are wrapped by a ResponseInterceptor: controller handlers return raw data and the interceptor sends { statusCode, data }, serializing data with instanceToPlain per pattern serialization so class instances and Dates become plain JSON. Errors are normalized by three global exception filters registered in AppModule: ZodExceptionFilter (catches ZodError from ZodValidationPipe, responds with 400 and fieldErrors), HttpExceptionFilter (Nest HTTP exceptions → shared envelope), and AllExceptionsFilter (catch-all, 500). They are registered with APP_FILTER in an order so that the specific filters run first and the catch-all handles the rest. ResponseInterceptor is registered with APP_INTERCEPTOR.
Frontend: frontends/shared/src/lib/api.ts exports handleRequest (get, post, put, patch, delete). handleRequest<T> returns ClientApiResponse<T> (success or client-error) on 2xx/4xx and throws ServerError on 5xx. The client adds the type discriminator when normalizing responses. Frontend services use handleRequest; when the service is used as a mutation mutationFn, the generic Form component maps the thrown ServerError to ServerErrorResponse and calls onError(response); onSuccess receives SuccessResponse<TOutput> so result.data is typed. When the request config includes transformTo (a DTO class), success data is deserialized with plainToInstance (see pattern serialization).
References
- Backend:
controller-layer,framework, exception filter, response interceptor, zod validation pipe;validation(zod). Seerequestsfor request handling. - Frontend:
http-client(handleRequest<T>),server-state,form-handling;fieldErrorsfor inline form errors. - Shared: zod schema and derived type for the envelope; pattern
sharing,validation,serialization,static-typing.