Skip to main content

Serialization pattern: class-transformer

Consistent serialization and deserialization across the API boundary so that Date values and class instances (including nested models) are correctly sent as JSON and, on the frontend, restored as real class instances with proper types.

Overview

The stack uses class-transformer (see stack rule serialization under stack/shared/): the backend serializes response data with instanceToPlain before sending; the frontend may deserialize success data with plainToInstance when a DTO class is provided, and serializes request bodies with instanceToPlain. Shared DTO/model classes define the shape with decorators so nested models and Dates behave correctly in both directions.

Shared models (DTOs)

DTO and model classes live in shared (e.g. shared/src/dto/ or shared/src/models/). They use class-transformer decorators so serialization and deserialization are predictable:

  • Nested classes: Use @Type(() => ChildClass) on properties that are other DTOs so they are instantiated when deserializing (e.g. on the frontend).
  • Date fields: Use two @Transform decorators so outbound is ISO string and inbound is Date:
    • @Transform(({ value }) => value?.toISOString?.() ?? value, { toPlainOnly: true })
    • @Transform(({ value }) => value != null ? new Date(value) : undefined, { toClassOnly: true }) Avoid relying on @Type(() => Date) alone when you need consistent ISO strings; the dual-@Transform pattern gives explicit control.
  • Visibility: Use @Expose() and @Exclude() as needed so only intended properties are serialized or deserialized.

Shared package depends on class-transformer and, if it contains these DTOs, must have experimentalDecorators and emitDecoratorMetadata enabled in tsconfig.json.

Backend

The ResponseInterceptor serializes the handler return value before wrapping it in the envelope: it runs instanceToPlain(data) so that class instances and Date fields become plain JSON (e.g. Dates as ISO strings, nested classes as plain objects). Controllers and services return domain objects (class instances or plain); the interceptor ensures the wire format is always plain. Services that map from an ORM (e.g. Prisma) can use plainToInstance(DtoClass, plain) so controllers return class instances that the interceptor then serializes. The backend depends on class-transformer and uses it in the interceptor.

Frontend

  • Responses: When the caller needs data as class instances (e.g. UserDto with createdAt: Date and nested ProfileDto), they pass the DTO class via the request config (e.g. transformTo: UserDto). handleRequest then runs plainToInstance(Cls, envelope.data) on success and returns the envelope with data as that class instance. When no class is passed, data remains the raw plain object (current behavior).
  • Request bodies: When calling post, put, or patch with a body, the client serializes the body with instanceToPlain(data) before sending so class instances and Dates are sent in the same format the backend expects. Callers can pass either plain objects or class instances; the client normalizes to plain.

The frontends/shared package depends on class-transformer and uses it in lib/api.ts for plainToInstance (success responses, when transformTo is set) and instanceToPlain (request bodies).

References

  • Stack rule: serialization (under stack/shared/) for the technology and decorator usage.
  • Pattern responses for the envelope; pattern requests for request handling and validation.
  • Backend: controller-layer, framework. Frontend: http-client.

Implementation

The boilerplate implements this pattern as follows.

  • Shared: The shared package lists class-transformer as a dependency. If the shared package contains DTO classes with decorators, shared/tsconfig.json has experimentalDecorators: true and emitDecoratorMetadata: true. DTOs are added under shared/src/ (e.g. dto/ or models/) as needed; the generation skill does not add an example DTO by default.
  • Backend: The backend lists class-transformer as a dependency. ResponseInterceptor (backend/src/interceptors/response.interceptor.ts) imports instanceToPlain from class-transformer and, before building the envelope, sets data to instanceToPlain(data) when data is present (otherwise leaves it unchanged). This ensures all success response payloads are plain JSON.
  • Frontend (frontends/shared): The frontends/shared package lists class-transformer as a dependency. lib/api.ts imports instanceToPlain and plainToInstance. The request config type is extended with an optional transformTo (a class constructor). On success, if transformTo is provided, envelope.data is replaced with plainToInstance(transformTo, envelope.data). For post, put, and patch, when a body is provided, the body is serialized with instanceToPlain(data) before being sent. Thus handleRequest.get<UserDto>(url, { transformTo: UserDto }) returns Promise<ApiResponse<UserDto>> with data as a UserDto instance when the response is successful.