Serialization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3, the version the Nest 12 CLI and templates install), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, class-transformer 0.5, Zod 4, Valibot 1.5 and ArkType 2.2, and was checked on 2026-10-02. The examples target a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest, socket.io-client), and the responses shown are what they return.
Serialization is the last transformation applied to a value before it is written to the network. It is the right place for rules such as "never send a password hash", "send only a subset of the entity" or "rename this field". Doing it by hand in every handler is tedious and error-prone: one forgotten delete user.passwordHash is a data leak. Nest ships two interceptors that apply these rules declaratively, in one place:
-
ClassSerializerInterceptorruns the handler’s return value through class-transformer’sinstanceToPlain(), so the decorators on your entity or DTO classes decide what is sent. -
StandardSchemaSerializerInterceptor, new in NestJS 12, runs the value through a schema written with Zod, Valibot, ArkType or any other library implementing the Standard Schema specification, and sends the schema’s output.
Both are interceptors in the sense of the Interceptors page: they pipe the handler’s result through an RxJS operator on the way out. Neither serializes StreamableFile responses, and both pass null, undefined and primitives through unchanged.
returns entity or row] --> I{Serializer
interceptor bound?} I -->|class-transformer| C["instanceToPlain
Exclude, Expose, Transform"] I -->|Standard Schema| S[schema validate
strip / transform / reject] I -->|no| L[Raw value
leaks every field] C --> R[JSON response] S --> R S -.->|issues| E[500 Internal Server Error] L --> R
Source: NestJS documentation: Serialization.
The running example
The examples below use a Bookshelf user. A repository returns a row that still carries a password hash, and the serializer must make sure the hash never reaches the client. The password is stored only as a hash (see Authentication for how it is produced); the placeholder values in the examples are obviously fake.
export interface UserRow {
id: number;
firstName: string;
lastName: string;
emailAddress: string;
passwordHash: string;
role: { id: number; name: string };
lastLoginIp: string;
profile: { displayName: string; taxId: string };
_internalNote: string;
}
ClassSerializerInterceptor
ClassSerializerInterceptor is exported from @nestjs/common and depends on the class-transformer package, which you install yourself (npm i class-transformer; Nest loads it when the interceptor is created). It takes the Reflector and optional default options as constructor arguments, which is why you normally bind it with @UseInterceptors() or APP_INTERCEPTOR and let Nest inject the Reflector.
Exclude properties
@Exclude() removes a property from the serialized output. Annotate the entity once and every handler that returns it is protected.
import { Exclude } from 'class-transformer';
export class UserEntity {
id: number;
firstName: string;
lastName: string;
// Never leaves the API, whichever handler returns the entity.
@Exclude()
passwordHash: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
Bind the interceptor to the controller (or to a single handler) and return an instance of the class:
import {
ClassSerializerInterceptor,
Controller,
Get,
Param,
ParseIntPipe,
UseInterceptors,
} from '@nestjs/common';
import { UserEntity } from './user.entity.js';
import { UsersService } from './users.service.js';
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// The handler returns an instance of UserEntity: its decorators apply.
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): UserEntity {
return this.usersService.findEntity(id);
}
// Arrays are serialized element by element.
@Get()
findAll(): UserEntity[] {
return this.usersService.findEntities();
}
}
curl http://localhost:3000/users/1
{
"id": 1,
"firstName": "Ada",
"lastName": "Lovelace",
...
}
The passwordHash property is absent; the full response, with every decorator from the sections below applied, is shown at the end of the next two sections. Returning an array applies the rules to each element.
The key requirement is that the handler returns an instance of the class. class-transformer finds the rules through the object’s constructor, so a plain object has no rules to apply. The pitfalls section below shows what happens when that assumption breaks.
Expose properties
@Expose() has two uses: renaming a property in the output, and exposing a computed value, such as a getter, as if it were a field.
import { Exclude, Expose } from 'class-transformer';
export class UserEntity {
// ...
// Renamed on the way out: `emailAddress` is sent as `email`.
@Expose({ name: 'email', toPlainOnly: true })
emailAddress: string;
// A computed property: a getter exposed like a regular field.
@Expose()
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
toPlainOnly: true limits a rule to the class-to-plain direction (the one serialization uses). It matters when a class is also used the other way round, as in the plain-object section below. The same options exist for @Exclude() and @Transform(); toClassOnly: true is the opposite direction, useful on input DTOs, where you want to read a field but never send it back.
Transform
@Transform() runs a function over the value. The following replaces a nested RoleEntity with its name:
import { Transform } from 'class-transformer';
// Replace the nested object with one of its properties.
@Transform(({ value }: { value: RoleEntity }) => value.name, { toPlainOnly: true })
role: RoleEntity;
The function receives a single object ({ value, key, obj, type, options }) and destructures value from it. The older signature @Transform(value ⇒ …), which received the value directly, was replaced in class-transformer 0.3; code written for it silently transforms the wrong thing today.
Nested objects with @Type
A nested object is serialized with its own class’s rules only when class-transformer can tell its class. When the value is already an instance (as when an ORM built it), that works. When the class has to be rebuilt from plain data, declare it with @Type():
import { Exclude, Type } from 'class-transformer';
export class ProfileEntity {
displayName: string;
@Exclude()
taxId: string;
}
export class UserEntity {
// ...
@Type(() => ProfileEntity)
profile: ProfileEntity;
}
A ValidationPipe input DTO that contains nested objects needs @Type() for the same reason (together with @ValidateNested()); see Pipes & Validation. Forgetting it is a common mistake in older material, including the reference book.
Pass options with @SerializeOptions
Options for instanceToPlain() are set with @SerializeOptions(), which can decorate a handler or the whole controller. They are forwarded to class-transformer as its options object. Among them are groups (serialize properties tagged for those groups), excludePrefixes (drop properties by prefix), strategy ('exposeAll', the default, or 'excludeAll'), version, ignoreDecorators, exposeUnsetFields and the others listed in the ClassTransformOptions interface of @nestjs/common.
import { SerializeOptions } from '@nestjs/common';
// Pass options to class-transformer with @SerializeOptions().
@Get(':id/admin-view')
@SerializeOptions({ groups: ['admin'], excludePrefixes: ['_'] })
adminView(@Param('id', ParseIntPipe) id: number): UserEntity {
return this.usersService.findEntity(id);
}
Together with @Expose({ groups: ['admin'] }) lastLoginIp?: string; on the entity, the default GET /users/1 omits lastLoginIp, while this handler returns it and drops _internalNote:
{
"id": 1,
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"role": "librarian",
"lastLoginIp": "203.0.113.7",
"profile": { "displayName": "Ada L." },
"fullName": "Ada Lovelace"
}
The default response of GET /users/1, by contrast, contains "_internalNote": "prefers the quiet room" and no lastLoginIp, because nothing excluded the prefix and no group was requested. Tag a group-restricted field with the group a trusted handler asks for, and never rely on groups alone to protect truly sensitive data; use @Exclude() for that.
When the handler carries @SerializeOptions() and the controller carries one too, the handler’s options replace the controller’s: the interceptor reads the metadata with Reflector.getAllAndOverride(), which returns the first value found, handler before class. They are not merged, so repeat any controller-level option you still need.
Transform plain objects with the type option
Repositories, raw queries, .lean() Mongoose documents and most query builders return plain objects, not class instances. Setting type in @SerializeOptions() makes the interceptor convert a plain value into an instance of that class (plainToInstance()) before serializing it, so the decorators apply without instantiating the class in every handler.
// `type` converts the plain value into UserEntity before serializing it.
@Get(':id/from-row')
@SerializeOptions({ type: UserEntity })
fromRow(@Param('id', ParseIntPipe) id: number): UserEntity {
return this.usersService.findRow(id) as unknown as UserEntity;
}
The response is the same as for the entity instance: no passwordHash, no taxId, email and fullName present. Note the toPlainOnly: true options on the alias and the @Transform() above. Without them, @Expose({ name: 'email' }) would look for an email key in the plain row (the row has emailAddress), and the @Transform() would run once on the plain input and again on the way out, so role would disappear. Both mistakes are easy to make; keep direction-specific rules direction-specific whenever a class serves both ways.
Declare the handler’s return type so TypeScript checks the shape of what you return. The as unknown as cast in the example is only there because the repository row is not a UserEntity; in real code you would type the repository method or map explicitly.
An allowlist with strategy excludeAll
With strategy: 'excludeAll', only properties decorated with @Expose() are sent, which inverts the default and makes a new column on the entity private until someone chooses to publish it. Combined with type, it turns a class into a response DTO:
import { Expose } from 'class-transformer';
// Used with strategy 'excludeAll': only the @Expose()d properties are sent.
export class UserSummaryDto {
@Expose()
id: number;
@Expose()
firstName: string;
@Expose()
lastName: string;
// No decorator: dropped, even though the source row has it.
passwordHash: string;
}
// in UsersController
@Get(':id/summary')
@SerializeOptions({ strategy: 'excludeAll', type: UserSummaryDto })
summary(@Param('id', ParseIntPipe) id: number): UserSummaryDto {
return this.usersService.findRow(id) as unknown as UserSummaryDto;
}
{ "id": 1, "firstName": "Ada", "lastName": "Lovelace" }
With excludeAll and type, the conversion from the plain row drops undecorated properties first. A getter that computes from properties you did not expose therefore sees undefined: a fullName getter over unexposed names produces "undefined undefined". Expose what the getter reads, or compute in the service.
Binding globally
To protect every handler, register the interceptor as a provider with the APP_INTERCEPTOR token so Nest injects the Reflector, or build it yourself in main.ts. The constructor’s second argument sets default options for every handler that does not override them.
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
import { APP_INTERCEPTOR, Reflector } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new ClassSerializerInterceptor(reflector, { excludePrefixes: ['_'] }),
inject: [Reflector],
},
],
})
export class AppModule {}
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
With the global interceptor and excludePrefixes: ['_'], a controller without any serializer binding returned the instance without _internalNote and without passwordHash. useClass: ClassSerializerInterceptor also works when you do not need default options. See Interceptors for how APP_INTERCEPTOR differs from useGlobalInterceptors().
Bind it once. Interceptors nest, so a controller-level ClassSerializerInterceptor inside a global one serializes the value twice. The inner one turns the entity into a plain object, and the outer one then has no class metadata left to work with. A global interceptor with strategy: 'excludeAll' plus a controller-level one produces an empty body, {}. Pick one place and keep it there.
Pitfalls
Three handlers show the ways serialization silently does nothing. They exist to be tested, not copied.
@Controller('pitfalls')
@UseInterceptors(ClassSerializerInterceptor)
export class PitfallsController {
constructor(private readonly usersService: UsersService) {}
// A plain object has no class metadata: the hash is sent. Do not do this.
@Get('plain')
plain() {
return this.usersService.findRow(1);
}
// A plain wrapper holding instances.
@Get('wrapped')
wrapped() {
return { data: this.usersService.findEntities(), total: 2 };
}
}
@Controller('pitfalls-unbound')
export class UnboundController {
constructor(private readonly usersService: UsersService) {}
// No interceptor is bound here, so @Exclude() is ignored.
@Get()
find() {
return this.usersService.findEntity(1);
}
}
-
GET /pitfalls/plainreturned the full row including"passwordHash":"$argon2id$placeholder-hash-1"and"taxId", because the value is a plain object and notypewas set. Use thetypeoption, return entity instances, or use a schema. -
GET /pitfalls-unboundreturned the entity with itspasswordHash, because the class decorators do nothing by themselves: the interceptor is what applies them. A global binding removes this class of mistake. -
GET /pitfalls/wrappedreturned a wrapper object whose entities were serialized (no hashes), because class-transformer walks nested values and applies the rules of any class instance it finds. The official page nevertheless warns that returning a plain object such as{ user: new UserEntity() }is not serialized correctly, since the wrapper’s own properties get no rules. Treat nested instances as working today, but do not rely on it for the wrapper itself; return the instance, or usetypewith a class that models the whole envelope.
The @Exclude() and @Expose() decorators are also not a replacement for choosing which columns to read: select only what the endpoint needs, and treat serialization as the safety net that still holds when someone adds a column later.
StandardSchemaSerializerInterceptor
StandardSchemaSerializerInterceptor (also exported from @nestjs/common, new in NestJS 12) shapes responses with a schema instead of class decorators. It runs the value returned by the handler through the schema’s ~standard.validate() method and sends the output. Any library implementing the Standard Schema specification works; Zod, Valibot and ArkType are the ones the official page names. There is no class-transformer dependency and no need to return class instances: the handler can return a database row, a Prisma result or a plain object.
A schema is an allowlist
Zod’s z.object() strips properties it does not declare. The schema therefore doubles as an allowlist, and a password hash cannot be sent even when the service returns the full row:
import { z } from 'zod';
// An allowlist: z.object() drops every property it does not declare.
export const userResponseSchema = z.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
});
export type UserResponse = z.infer<typeof userResponseSchema>;
Bind the interceptor and select the schema with the schema option of @SerializeOptions():
import {
Controller,
Get,
Param,
ParseIntPipe,
SerializeOptions,
StandardSchemaSerializerInterceptor,
UseInterceptors,
} from '@nestjs/common';
import { userResponseSchema } from './user-response.schema.js';
import { UsersService } from './users.service.js';
@Controller('accounts')
@UseInterceptors(StandardSchemaSerializerInterceptor)
@SerializeOptions({ schema: userResponseSchema }) // default for every handler here
export class AccountsController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findRow(id); // still contains passwordHash
}
// Arrays: the schema of ONE item, applied to each element.
@Get()
findAll() {
return this.usersService.findRows();
}
}
curl http://localhost:3000/accounts/1
{ "id": 1, "firstName": "Ada", "lastName": "Lovelace" }
When the handler returns an array, GET /accounts here, the interceptor applies the schema to each element, so pass the schema of a single item, not an array schema. The response is [{"id":1,…},{"id":2,…}] with no hashes.
@SerializeOptions() can decorate the class or the handler. A schema on the handler takes precedence over one on the class, with the same replace-not-merge behaviour described above.
Transform responses with schemas
Schema transformations cover what @Expose() and @Transform() do for classes: computed fields, renames and flattened nested objects. The schema below adds fullName and replaces the nested role object with its name:
export const userSummarySchema = z
.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
role: z.object({ id: z.number(), name: z.string() }),
})
.transform((user) => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`,
role: user.role.name,
}));
// A handler-level schema takes precedence over the class-level one.
@Get(':id/summary')
@SerializeOptions({ schema: userSummarySchema })
summary(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findRow(id);
}
{ "id": 1, "fullName": "Ada Lovelace", "role": "librarian" }
The same schema written with Valibot uses v.pipe() and v.transform(); it produces the identical response:
import * as v from 'valibot';
export const userSummarySchema = v.pipe(
v.object({
id: v.number(),
firstName: v.string(),
lastName: v.string(),
role: v.object({ id: v.number(), name: v.string() }),
}),
v.transform((user) => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`,
role: user.role.name,
})),
);
and with ArkType, whose object types keep undeclared keys unless told otherwise, so '+': 'delete' is what turns the type into an allowlist:
import { type } from 'arktype';
// ArkType object types strip undeclared keys when the option is set.
export const userSummarySchema = type({
'+': 'delete',
id: 'number',
firstName: 'string',
lastName: 'string',
}).pipe((user) => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`,
}));
The ArkType endpoint returned {"id":1,"fullName":"Ada Lovelace"} and never the hash. The interceptor awaits validate(), so schemas with asynchronous refinements also work, though response schemas rarely need them.
Binding globally and default options
To bind the interceptor application-wide, hand it the Reflector, which it uses to read the @SerializeOptions() metadata, or register it with APP_INTERCEPTOR and let Nest inject it. Handlers without a schema are not affected. The constructor’s second argument sets defaults that apply when neither handler nor controller sets a schema:
-
schema: the default schema; -
validateOptions: options forwarded to the schema’s~standard.validate(), for libraries that support them. A handler can override them with thevalidateOptionsoption of@SerializeOptions().
import { StandardSchemaSerializerInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(
new StandardSchemaSerializerInterceptor(app.get(Reflector)),
);
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new StandardSchemaSerializerInterceptor(reflector, { schema: defaultResponseSchema }),
inject: [Reflector],
}
A global interceptor created with a default schema of { id, firstName } returned {"id":1,"firstName":"Ada"} for a handler that had no @SerializeOptions() at all. Be careful with such a default: it applies to every handler that returns an object, so any endpoint with a different shape needs its own schema, or will fail the way the next section describes.
Serialization errors
A response that does not match its schema, such as a missing or mistyped property, means the server has a bug; it is not the client’s fault. The interceptor therefore throws an error listing the schema’s issues, and the client receives 500 Internal Server Error instead of partially serialized data. The message, with the issues, is logged by the default exception filter and not sent to the client.
// The handler returns data that does not match the schema: a server bug.
@Get('broken/one')
broken() {
return { id: 'not-a-number', firstName: 'Ada' };
}
{ "statusCode": 500, "message": "Internal server error" }
The server log contains Serialization failed: Invalid input: expected number, received string, Invalid input: expected string, received undefined. The error is a plain Error, not an HttpException, so a custom exception filter can catch it only by matching Error generally. This fail-closed behaviour is the point of a response schema: it is also a contract test that runs on every request. Cover the paths that return unusual data in tests.
A null result is sent unchanged (an empty body, as for any null return), and so is a handler without a schema.
Choosing between the two
ClassSerializerInterceptor |
StandardSchemaSerializerInterceptor |
|
|---|---|---|
Rules live in |
Decorators on the entity or DTO class |
A schema object, independent of any class |
Handler must return |
A class instance, or set |
Anything the schema accepts (rows, plain objects, instances) |
Default stance |
Expose everything, hide with |
Declare what is sent; undeclared keys are stripped (check your library: ArkType needs |
Wrong data |
Passes through unchanged |
Rejected: |
Other uses |
Same class often doubles as the validation DTO |
The same Zod, Valibot or ArkType schemas can feed |
Needs |
|
A Standard Schema library of your choice |
An existing class-validator and class-transformer code base can keep using ClassSerializerInterceptor; a new code base, or one that already uses Zod, Valibot or ArkType for validation, gets a single schema vocabulary for input and output with the Standard Schema interceptor. The two are independent interceptors; use one per route, not both.
WebSockets and microservices
Both interceptors work the same in HTTP, WebSocket and microservice contexts, whatever the transport: they only see the value the handler returns. The official WebSocket page adds that there is no difference between regular and WebSocket interceptors, and that interceptors registered with app.useGlobalInterceptors() or APP_INTERCEPTOR apply to gateways as well.
A gateway handler, with the interceptor on the gateway class, returns an entity whose hash must not reach the client:
import { ClassSerializerInterceptor, UseInterceptors } from '@nestjs/common';
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { UserEntity } from './user.entity.js';
import { UsersService } from './users.service.js';
@WebSocketGateway()
@UseInterceptors(ClassSerializerInterceptor)
export class UsersGateway {
constructor(private readonly usersService: UsersService) {}
// The acknowledgement carries the serialized value: no passwordHash.
@SubscribeMessage('user:get')
getUser(@MessageBody() id: number): UserEntity {
return this.usersService.findEntity(id);
}
}
With a Socket.IO client, socket.emit('user:get', 1, callback) received the serialized object in the acknowledgement: id, firstName, lastName, email, role: "librarian", profile without taxId, fullName, and no passwordHash. See WebSockets for gateways in depth.
The same applies to a message handler in a microservice. Here a TCP transport; a client that sends { cmd: 'users.get' } receives the serialized object:
import { ClassSerializerInterceptor, Controller, UseInterceptors } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { UserEntity } from './user.entity.js';
import { UsersService } from './users.service.js';
@Controller()
@UseInterceptors(ClassSerializerInterceptor)
export class UsersRpcController {
constructor(private readonly usersService: UsersService) {}
@MessagePattern({ cmd: 'users.get' })
getUser(@Payload() id: number): UserEntity {
return this.usersService.findEntity(id);
}
}
The reply is JSON on the wire in this transport, so what the other service receives is the serialized plain object, not an entity instance. That is another reason to serialize at the boundary of every service, not only at the HTTP edge: an internal message that carries a hash is still a leak if it is logged or forwarded. See Microservices for transports and patterns.
Express and Fastify
Serialization is performed in an interceptor, before the platform adapter writes the response, so ClassSerializerInterceptor and StandardSchemaSerializerInterceptor behave identically on Express 5 and Fastify 5. The examples use the default Express adapter. Neither interceptor changes headers or status codes; for StreamableFile responses, such as file downloads, serialization is skipped, so file streaming is unaffected (see File upload and streaming).
What changed recently
-
NestJS 12 adds
StandardSchemaSerializerInterceptorand theschemaandvalidateOptionsoptions of@SerializeOptions(); the Standard Schema validation pipe shipped in the same release. The migration guide lists it with the other Standard Schema additions for validation, configuration and OpenAPI. -
@SerializeOptions()now accepts either class-transformer options or schema options; thetypeoption still belongs to the class-transformer variant. -
NestJS 12 packages are ESM-only; the examples use the
.jsimport extensions of the ESM template.class-transformeritself is unchanged at 0.5.1, which is why the Standard Schema interceptor is attractive for new code.
Where the book is outdated
The reference book, published in January 2025 (see the section bibliography), returns user entities that contain password hashes without binding ClassSerializerInterceptor or marking the field with @Exclude(), which exposes the hash to every client. It predates StandardSchemaSerializerInterceptor entirely. Its use of @Transform() is also from the pre-0.3 era (a function that receives the value directly), and nested objects are declared without @Type(). The fix for each is above: @Exclude() plus a bound interceptor (or a response schema), the destructured { value } signature, and @Type(() ⇒ Nested).