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:

  • ClassSerializerInterceptor runs the handler’s return value through class-transformer’s instanceToPlain(), 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.

flowchart LR H[Route handler
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

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.

users.service.ts (excerpt: the shape of a row as a repository returns it)
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.

user.entity.ts (excerpt)
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:

users.controller.ts
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.

user.entity.ts (excerpt)
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:

user.entity.ts (excerpt)
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():

profile.entity.ts and user.entity.ts (excerpts)
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.

users.controller.ts (excerpt)
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.

users.controller.ts (excerpt)
// `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:

user-summary.dto.ts and users.controller.ts (excerpts)
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.

Global binding with dependency injection
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 {}
Global binding in main.ts
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.

pitfalls.controller.ts (excerpt)
@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/plain returned the full row including "passwordHash":"$argon2id$placeholder-hash-1" and "taxId", because the value is a plain object and no type was set. Use the type option, return entity instances, or use a schema.

  • GET /pitfalls-unbound returned the entity with its passwordHash, because the class decorators do nothing by themselves: the interceptor is what applies them. A global binding removes this class of mistake.

  • GET /pitfalls/wrapped returned 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 use type with 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:

user-response.schema.ts
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():

accounts.controller.ts
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:

user-response.schema.ts (excerpt)
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,
  }));
accounts.controller.ts (excerpt)
// 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:

user-summary.valibot.ts
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:

user-summary.arktype.ts
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 the validateOptions option of @SerializeOptions().

main.ts
import { StandardSchemaSerializerInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(
  new StandardSchemaSerializerInterceptor(app.get(Reflector)),
);
Registering with a default schema through APP_INTERCEPTOR
{
  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.

accounts.controller.ts (excerpt)
// 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 type

Anything the schema accepts (rows, plain objects, instances)

Default stance

Expose everything, hide with @Exclude() (switch with strategy: 'excludeAll')

Declare what is sent; undeclared keys are stripped (check your library: ArkType needs '+': 'delete')

Wrong data

Passes through unchanged

Rejected: 500

Other uses

Same class often doubles as the validation DTO

The same Zod, Valibot or ArkType schemas can feed @Body({ schema }) and StandardSchemaValidationPipe, covered on Pipes & Validation

Needs

class-transformer

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:

users.gateway.ts
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:

users.rpc.controller.ts
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 StandardSchemaSerializerInterceptor and the schema and validateOptions options 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; the type option still belongs to the class-transformer variant.

  • NestJS 12 packages are ESM-only; the examples use the .js import extensions of the ESM template. class-transformer itself 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).