Serialization
Serialization happens before objects are returned in a network response. It is the right place to define rules for transforming and sanitizing the data sent to the client. For example, sensitive data such as passwords must always be excluded from the response, and some properties may need further transformation, such as returning only a subset of an entity's properties. Performing these transformations manually in every handler is tedious and error-prone, and makes it hard to be sure that every case is covered.
Overview#
Nest provides two built-in interceptors that apply these rules declaratively:
ClassSerializerInterceptoruses the class-transformer package. It takes the value returned by a route handler and applies theinstanceToPlain()function to it, which honors theclass-transformerdecorators declared on the entity or DTO class. The first part of this chapter covers this approach.StandardSchemaSerializerInterceptorvalidates and transforms responses with a schema written in Zod, Valibot, ArkType, or any other Standard Schema compatible library. See Schema-based serialization.
Hint Neither interceptor serializes StreamableFile responses.
Exclude properties#
Suppose you want to exclude the password property of a user entity from every response. Annotate the entity as follows:
import { Exclude } from 'class-transformer';
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude()
password: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
Now consider a controller with a route handler that returns an instance of this class:
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): UserEntity {
return new UserEntity({
id: 1,
firstName: 'John',
lastName: 'Doe',
password: 'password',
});
}
Warning The handler must return an instance of the class. A plain JavaScript object, such as { user: new UserEntity() }, is not serialized correctly. To serialize plain objects, see Transform plain objects.
Hint TheClassSerializerInterceptoris imported from@nestjs/common.
When this endpoint is requested, the client receives the following response:
{
"id": 1,
"firstName": "John",
"lastName": "Doe"
}
The interceptor can also be bound application-wide (see Binding interceptors). Together, the interceptor and the entity class declaration ensure that every route handler that returns a UserEntity omits the password property, which enforces this rule in one place.
Expose properties#
Use the @Expose() decorator to provide alias names for properties, or to compute a property value with a function (similar to a getter), as shown below.
@Expose()
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
Transform#
Use the @Transform() decorator for additional data transformation. For example, the following construct returns the name property of the RoleEntity instead of the whole object.
@Transform(({ value }) => value.name)
role: RoleEntity;
Pass options#
To override the default behavior of the transformation functions, pass options to the @SerializeOptions() decorator.
@SerializeOptions({
excludePrefixes: ['_'],
})
@Get()
findOne(): UserEntity {
return new UserEntity();
}
Hint The@SerializeOptions()decorator is imported from@nestjs/common.
Options passed to @SerializeOptions() are forwarded as the second argument of the underlying instanceToPlain() function. In this example, all properties that begin with the _ prefix are excluded.
Transform plain objects#
The type option of @SerializeOptions() converts responses into instances of the specified class before they are serialized, so the class's class-transformer decorators apply even when the handler returns a plain object. This removes the need to instantiate the class or call plainToInstance() in every handler.
In the example below, both conditional branches return plain JavaScript objects, which are converted into UserEntity instances with the relevant decorators applied:
@UseInterceptors(ClassSerializerInterceptor)
@SerializeOptions({ type: UserEntity })
@Get()
findOne(@Query() { id }: { id: number }): UserEntity {
if (id === 1) {
return {
id: 1,
firstName: 'John',
lastName: 'Doe',
password: 'password',
};
}
return {
id: 2,
firstName: 'Kamil',
lastName: 'Mysliwiec',
password: 'password2',
};
}
Hint Declaring the handler's return type lets TypeScript check that the returned plain object matches the shape of the DTO or entity. The plainToInstance() function doesn't provide this type checking, so a mismatched object can go unnoticed.
Schema-based serialization#
The StandardSchemaSerializerInterceptor shapes responses with a schema instead of class decorators. The schema describes exactly what leaves the API: the interceptor runs the value returned by the route handler through the schema and sends the schema's output to the client. It accepts any schema that implements the Standard Schema specification. The examples in this section use Zod.
Because z.object() strips properties the schema doesn't declare, a response schema is also an allowlist. The following schema guarantees that a user's password is never sent, even when the service returns the full database row:
import { z } from 'zod';
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 the @SerializeOptions() decorator:
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('users')
@UseInterceptors(StandardSchemaSerializerInterceptor)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
@SerializeOptions({ schema: userResponseSchema })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Get()
@SerializeOptions({ schema: userResponseSchema })
findAll() {
return this.usersService.findAll();
}
}
The service returns user records that include a password property, but the client receives only the declared properties:
{
"id": 1,
"firstName": "John",
"lastName": "Doe"
}
When a handler returns an array, as findAll() does, the interceptor applies the schema to each element. Pass the schema of a single item, not an array schema.
null, undefined, primitive values, and StreamableFile responses are sent unchanged, as are responses of handlers without a schema.
Transforming responses with schemas#
Schema transformations cover what @Expose() and @Transform() do for classes. The following schema adds a computed fullName property and replaces the nested role object with its name:
import { z } from 'zod';
export const userResponseSchema = 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,
}));
For a user with the admin role, the client receives:
{
"id": 1,
"fullName": "John Doe",
"role": "admin"
}
The same schema written with Valibot uses v.pipe() and v.transform():
import * as v from 'valibot';
export const userResponseSchema = 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,
})),
);
Binding the schema serializer#
@SerializeOptions() can decorate a controller class as well as a route handler. A schema set on the class applies to all of its handlers, and a schema set on a handler takes precedence:
@Controller('users')
@UseInterceptors(StandardSchemaSerializerInterceptor)
@SerializeOptions({ schema: userResponseSchema })
export class UsersController {}
To bind the interceptor application-wide, pass it the Reflector instance, which the interceptor uses to read the @SerializeOptions() metadata:
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(
new StandardSchemaSerializerInterceptor(app.get(Reflector)),
);
Alternatively, register it with the APP_INTERCEPTOR token, as described in Binding interceptors, and Nest injects the Reflector for you. Handlers without a schema are not affected by the global interceptor.
The interceptor constructor also accepts default options, which apply when neither the handler nor its controller sets a schema:
schema | The default schema used when @SerializeOptions() doesn't provide one. |
validateOptions | Options forwarded to the schema's ~standard.validate() method, for libraries that support them. A handler can override them with the validateOptions option of @SerializeOptions(). |
Serialization errors#
A response that doesn't match its schema, such as a record with a missing or mistyped property, indicates a bug on the server, not a problem with the request. In that case, the interceptor throws an error that lists the schema's issues, and the client receives a 500 Internal Server Error response instead of partially serialized data. The error message, including the issues, is logged by the default exception filter.
A working example that uses Valibot is available here.
Example#
A working example of the ClassSerializerInterceptor is available here.
WebSockets and Microservices#
While this chapter shows examples using HTTP applications (e.g., Express or Fastify), both ClassSerializerInterceptor and StandardSchemaSerializerInterceptor work the same for WebSockets and microservices, regardless of the transport.
Learn more#
Read more about available decorators and options in the class-transformer repository. For schema libraries, see the Zod, Valibot, and ArkType documentation.

