Skip to main content

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, optional message?: string.
  • Success (2xx): required data: T, optional message.
  • Client error (4xx): optional message, optional error?: string (Nest-style short description), optional errors?: string[], optional fieldErrors?: Record<string, string[]> (per-field, e.g. from zod).
  • Server error (5xx): optional message, optional error?: string (no errors or fieldErrors).

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 by ZodValidationPipe on validation failure. Map to the client-error envelope: build fieldErrors: Record<string, string[]> from the Zod error (e.g. Zod’s flattenError / fieldErrors). Respond with { statusCode: 400, message: 'Validation failed', errors?, fieldErrors }.
  • HttpExceptionFilter (@Catch(HttpException)): get status via exception.getStatus() and body via exception.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; required data: T; optional message. Narrow with response.type === 'success'.
  • ClientErrorResponsetype: 'client-error'; optional message, error, errors, fieldErrors. Narrow with response.type === 'client-error'.
  • ServerErrorResponsetype: 'server-error'; optional message, error. Narrow with response.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). See requests for request handling.
  • Frontend: http-client (handleRequest<T>), server-state, form-handling; fieldErrors for inline form errors.
  • Shared: zod schema and derived type for the envelope; pattern sharing, validation, serialization, static-typing.