接口开发到一定阶段后,返回值中通常会出现两类重复代码:一类用于删除 password、token 等敏感字段,另一类用于格式化日期、枚举和关联对象。把这些逻辑散落在 Controller 或 Service 中,不仅难以复用,也很容易在新增接口时漏掉。
NestJS 内置的 ClassSerializerInterceptor 基于 class-transformer,可以在响应发出前把类实例转换为普通对象,并执行声明在实体类或响应 DTO 上的序列化规则。本文结合一个 TypeORM 项目中的实际改造,介绍如何统一输出 YYYY-MM-DD HH:mm:ss 格式的时间,并进一步说明字段隐藏、字段重命名、派生字段、分组、版本控制和嵌套对象等进阶用法。
本文示例基于 NestJS 11、
class-transformer0.5.x 和 TypeScript 5。核心思路同样适用于其他近期版本。
一、先理解完整的响应序列化链路
NestJS 的响应序列化发生在 Controller 返回结果之后、HTTP 响应发送之前:
TypeORM 查询
-> 返回实体实例
-> Controller 返回结果
-> ClassSerializerInterceptor
-> class-transformer.instanceToPlain()
-> JSON 响应
其中各部分职责不同:
- TypeORM 装饰器负责实体与数据库字段之间的映射。
class-transformer装饰器描述类实例如何转换成普通对象。ClassSerializerInterceptor在 NestJS 响应阶段自动触发转换。JSON.stringify()最终把转换后的普通对象编码为 JSON。
只添加 @Transform() 并不会自动修改 HTTP 响应,必须同时确保序列化拦截器生效。反过来,只注册拦截器而没有声明转换规则,响应字段也不会凭空改变。
二、安装与 TypeScript 配置
安装依赖:
pnpm add class-transformer reflect-metadata
如果需要像本文一样格式化日期,可以同时安装 dayjs:
pnpm add dayjs
命令说明:
class-transformer提供实例转换函数以及@Transform()、@Exclude()、@Expose()等装饰器。reflect-metadata为装饰器元数据提供运行时支持,NestJS 项目通常已经安装。dayjs用于日期格式化,也可以换成项目已有的日期库或原生实现。
确认 tsconfig.json 已启用装饰器:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Nest CLI 创建的标准项目通常已经包含这两项配置。
三、全局启用 ClassSerializerInterceptor
如果多数接口都需要统一序列化,建议在根模块中通过 APP_INTERCEPTOR 注册全局拦截器:
import { ClassSerializerInterceptor, Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { AppController } from './app.controller'
import { AppService } from './app.service'
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor,
},
],
})
export class AppModule {}
使用 APP_INTERCEPTOR 的好处是拦截器仍由 NestJS 依赖注入容器管理,并且不需要在每个 Controller 上重复添加装饰器。
如果只有少量接口需要序列化,也可以缩小作用范围:
import {
ClassSerializerInterceptor,
Controller,
Get,
UseInterceptors,
} from '@nestjs/common'
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserController {
@Get()
findAll() {
return []
}
}
全局注册和局部注册选择一种即可,通常不需要重复使用。
四、使用 @Transform 统一格式化日期
项目中的 TypeORM 实体都继承同一个 BaseEntity,因此把时间转换规则放在基类中,可以一次覆盖所有业务实体:
import { Transform, type TransformFnParams } from 'class-transformer'
import dayjs from 'dayjs'
import {
CreateDateColumn,
DeleteDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
const transformDate = ({ value }: TransformFnParams): string => {
if (value instanceof Date) {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
return ''
}
export abstract class BaseEntity {
@PrimaryGeneratedColumn({ type: 'int', comment: 'Unique identifier' })
id!: number
@Transform(transformDate, { toPlainOnly: true })
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@Transform(transformDate, { toPlainOnly: true })
@UpdateDateColumn({
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
updatedAt!: Date
@DeleteDateColumn({ type: 'timestamp', nullable: true })
deletedAt!: Date | null
}
这里最关键的是:
value是当前字段在转换时的值。value instanceof Date避免对异常值直接调用日期格式化逻辑。{ toPlainOnly: true }表示规则只在“类实例转普通对象”时执行,不影响“普通对象转类实例”的入参转换。- TypeORM Repository 的
find()、findOne()等方法通常返回实体实例,因此能够直接触发实体上的装饰器。
接口返回结果会由:
{
"createdAt": "2026-07-20T02:30:00.000Z",
"updatedAt": "2026-07-20T03:00:00.000Z"
}
转换为:
{
"createdAt": "2026-07-20 10:30:00",
"updatedAt": "2026-07-20 11:00:00"
}
日期格式化中的时区问题
dayjs(value).format() 会按照 Node.js 进程的本地时区输出。数据库时区、数据库驱动时区、服务器时区不一致时,即使格式看起来正确,时间仍可能相差数小时。
生产项目应先确定接口时间规范,常见选择有两种:
- 统一返回 ISO 8601 UTC,例如
2026-07-20T02:30:00.000Z,由客户端按用户时区显示。 - 统一返回约定时区的字符串,并在接口文档中明确时区,例如
Asia/Shanghai。
单纯把 ISO 字符串改为 YYYY-MM-DD HH:mm:ss 会丢失时区信息,适合时区约定明确的内部系统,不建议在跨地区系统中含糊使用。
对于可空时间字段,更稳妥的转换方式是保留 null,避免客户端把空字符串误判为有效时间:
const transformNullableDate = ({ value }: TransformFnParams): string | null => {
if (value === null || value === undefined) {
return null
}
return value instanceof Date
? dayjs(value).format('YYYY-MM-DD HH:mm:ss')
: null
}
五、类实例与普通对象:最常见的失效原因
class-transformer 的装饰器元数据声明在类上。下面两段代码在 TypeScript 类型层面看起来接近,运行时却不同:
const instance = new UserResponseDto()
instance.id = 1
const plainObject = {
id: 1,
}
前者是 UserResponseDto 实例,后者只是普通 JavaScript 对象。TypeScript 的类型断言不会改变运行时对象:
// 这仍然是普通对象,不会因为 as 而拥有类的装饰器元数据
return data as UserResponseDto
当查询结果来自原生 SQL、TypeORM getRawMany()、外部 HTTP 接口或手工对象拼装时,可以显式转换:
import { plainToInstance } from 'class-transformer'
const users = await this.repository
.createQueryBuilder('user')
.select(['user.id AS id', 'user.username AS username'])
.getRawMany()
return plainToInstance(UserResponseDto, users)
NestJS 新版也支持通过 @SerializeOptions({ type }) 指定响应类型,让普通对象先转换为目标类:
import { Controller, Get, SerializeOptions } from '@nestjs/common'
@Controller('users')
export class UserController {
@Get(':id')
@SerializeOptions({ type: UserResponseDto })
findOne(): UserResponseDto {
return {
id: 1,
username: 'admin',
password: 'should-not-be-returned',
}
}
}
如果响应有统一包装层,例如 { code, message, data },不要只依赖 TypeScript 接口描述它。可为包装结构建立响应类,并用 @Type() 标注嵌套数据;或者在进入包装层之前完成 plainToInstance()。尤其应对实际接口做集成测试,确认嵌套对象上的 @Exclude() 确实执行。
六、隐藏敏感字段:@Exclude
@Exclude() 可以在序列化时移除字段。建议显式添加 { toPlainOnly: true },避免它同时影响反序列化:
import { Exclude } from 'class-transformer'
export class UserResponseDto {
id!: number
username!: string
@Exclude({ toPlainOnly: true })
password!: string
}
返回 UserResponseDto 实例时,最终 JSON 中不会出现 password。
对于 TypeORM 密码字段,还可以同时使用 select: false:
@Exclude({ toPlainOnly: true })
@Column({ type: 'varchar', length: 255, select: false })
password!: string
两者不是重复配置,而是两道不同防线:
select: false尽量不从数据库读取敏感字段。@Exclude()防止字段已被显式查询或手工赋值后意外进入响应。
安全边界较高的项目更推荐单独定义 Response DTO,而不是直接向外暴露数据库实体。
七、字段重命名与派生字段:@Expose
重命名响应字段
import { Expose } from 'class-transformer'
export class UserResponseDto {
@Expose({ name: 'userId' })
id!: number
}
响应结果为:
{
"userId": 1
}
输出 getter 计算结果
export class UserResponseDto {
firstName!: string
lastName!: string
@Expose()
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
fullName 不需要保存到数据库,也能作为派生字段进入响应。getter 中应避免数据库查询、网络请求或修改状态,因为它可能在序列化过程中被调用多次。
八、@Transform 的更多用法
@Transform() 的回调参数除了 value,还包括当前对象 obj、属性名 key、转换方向 type 和本次转换配置 options。
压缩关联对象
@Transform(({ value }) => value?.name ?? null, { toPlainOnly: true })
role!: RoleEntity | null
原本的关联对象:
{
"role": {
"id": 2,
"name": "editor",
"permissions": []
}
}
会被转换为:
{
"role": "editor"
}
组合当前对象中的多个字段
@Transform(({ obj }) => `${obj.firstName} ${obj.lastName}`, {
toPlainOnly: true,
})
displayName!: string
如果 displayName 本身不存在,优先考虑 @Expose() 配合 getter,语义通常更清晰。
分清两个转换方向
@Transform(({ value }) => String(value).trim(), { toClassOnly: true })
title!: string
toPlainOnly:实例转普通对象,主要用于格式化响应。toClassOnly:普通对象转实例,主要用于清洗输入。
不要在响应格式化装饰器中省略方向限制,否则同一规则可能在请求 DTO 转换时也执行,产生难以发现的副作用。
九、白名单输出:excludeAll 策略
当响应包含敏感信息时,与其逐个排除不允许返回的字段,不如只暴露明确允许返回的字段:
import { Expose } from 'class-transformer'
export class UserResponseDto {
@Expose()
id!: number
@Expose()
username!: string
password!: string
resetToken!: string
}
在接口上启用白名单策略:
@Get(':id')
@SerializeOptions({
type: UserResponseDto,
strategy: 'excludeAll',
})
findOne() {
return this.userService.findOne()
}
最终只有带 @Expose() 的字段会输出。新增实体字段时不会自动泄漏到 API,更适合公开接口和权限敏感接口。
十、按用户角色输出字段:Groups
同一个响应 DTO 可以通过 groups 为不同调用方输出不同字段:
import { Expose } from 'class-transformer'
export class UserResponseDto {
@Expose()
id!: number
@Expose()
username!: string
@Expose({ groups: ['admin'] })
email!: string
@Expose({ groups: ['admin'] })
lastLoginIp!: string
}
管理员接口指定 admin 分组:
@Get(':id')
@SerializeOptions({
type: UserResponseDto,
groups: ['admin'],
})
findOneForAdmin() {
return this.userService.findOne()
}
groups 适用于少量、稳定的展示差异。如果规则取决于资源归属、租户、动态权限或当前用户上下文,建议在授权层或专门的响应映射层处理,不要把复杂权限系统全部塞进装饰器。
十一、按 API 版本控制字段
@Expose() 支持 since 和 until,可以结合 @SerializeOptions({ version }) 控制字段在哪个序列化版本出现:
export class UserResponseDto {
@Expose({ until: 2 })
name!: string
@Expose({ since: 2 })
displayName!: string
}
@Get(':id')
@SerializeOptions({
type: UserResponseDto,
version: 2,
})
findOneV2() {
return this.userService.findOne()
}
这里的 version 是 class-transformer 的序列化选项,并不会自动读取 NestJS URI 版本 v1、v2。如果项目使用 NestJS API Versioning,需要自行保证路由版本与序列化版本一致。
十二、嵌套对象与数组:@Type
当普通对象被转换为类实例时,运行时无法仅凭 TypeScript 类型推断嵌套属性的构造函数,需要使用 @Type():
import { Exclude, Expose, Type } from 'class-transformer'
class ProfileResponseDto {
@Expose()
nickname!: string
@Exclude({ toPlainOnly: true })
internalRemark!: string
}
export class UserResponseDto {
@Expose()
id!: number
@Expose()
@Type(() => ProfileResponseDto)
profile!: ProfileResponseDto
@Expose()
@Type(() => ProfileResponseDto)
histories!: ProfileResponseDto[]
}
然后转换原始数据:
return plainToInstance(UserResponseDto, rawUser, {
excludeExtraneousValues: true,
})
excludeExtraneousValues: true 会只保留带 @Expose() 的属性,适合把不可信或字段过多的普通对象映射为严格的响应 DTO。它依赖 @Expose() 白名单,开启后漏加装饰器的字段也会被删除。
十三、接口级覆盖配置:@SerializeOptions
@SerializeOptions() 接收的配置会传给底层转换过程,常见选项包括:
@SerializeOptions({
type: UserResponseDto,
groups: ['admin'],
version: 2,
strategy: 'excludeAll',
excludePrefixes: ['_'],
exposeUnsetFields: false,
})
type:把普通返回值转换为指定类,NestJS 项目中非常实用。groups:启用指定字段分组。version:启用指定序列化版本。strategy:可设为excludeAll,只输出显式暴露的字段。excludePrefixes:排除指定前缀,例如_internalId。exposeUnsetFields: false:不输出值为undefined的字段。
这些选项可以标注在方法或 Controller 类上。通用规则放到类级别,少量例外放到方法级别,能避免配置散落。
十四、直接暴露 Entity,还是建立 Response DTO
小型内部项目可以直接在 TypeORM Entity 上添加序列化装饰器,优点是代码少,本文的基础时间格式化就属于这种方式。
随着项目变复杂,建议把数据库实体与 API 响应模型分开:
export class UserResponseDto {
@Expose()
id!: number
@Expose()
username!: string
@Expose()
@Transform(transformNullableDate, { toPlainOnly: true })
createdAt!: Date
}
@Get()
@SerializeOptions({ type: UserResponseDto })
findAll(): Promise<UserResponseDto[]> {
return this.userService.findAll()
}
独立 Response DTO 的优势是:
- 数据库字段变化不会直接改变外部 API。
- 可以针对创建、更新、详情、列表分别设计模型。
- 敏感字段采用白名单输出,默认更安全。
- Swagger 文档、类型声明和真实响应更容易保持一致。
代价是需要维护映射和更多类型。通常可以先在实体基类处理真正全局的格式,例如时间;对外接口再逐步迁移到专用 Response DTO。
十五、不会生效或容易踩坑的场景
1. 返回的是普通对象
as UserResponseDto 只影响编译期,不会创建实例。使用 @SerializeOptions({ type: UserResponseDto })、构造函数或 plainToInstance()。
2. 使用 @Res() 手工发送响应
下面的代码绕过了 NestJS 标准响应处理链:
@Get()
findAll(@Res() response: Response) {
response.json(this.userService.findAll())
}
拦截器无法按常规方式处理这个返回值。优先直接 return 数据;确实需要操作原生响应时,可以考虑 @Res({ passthrough: true }),并让方法仍然返回数据。
3. 返回 StreamableFile
文件流响应不适用常规类序列化,也没有格式化业务字段的必要,应与 JSON 响应分开处理。
4. 只修改数据库列类型
TypeORM 的 timestamp、datetime 决定数据库如何保存和读取数据,不等于 HTTP 响应格式。响应格式属于 API 序列化层。
5. 把格式化逻辑写进每个 Controller
手工 map() 能短期解决问题,但重复代码会迅速增加。通用规则放到基类或 Response DTO,特殊展示需求再在具体 DTO 中声明。
6. 使用箭头属性充当 @Transform 目标但未暴露
转换通常依附于已有字段。如果目标字段不存在于原始对象,又希望凭空生成字段,应使用 @Expose() 配合 getter,或同时明确配置暴露策略并编写测试。
7. 忽略序列化成本
序列化会遍历对象。返回超大列表、深层关联或包含大量 getter 时会增加 CPU 和内存开销。应先做好分页、限制关联深度,并避免在 getter 和转换函数中执行重逻辑。
十六、如何测试序列化结果
单元测试可以直接验证 class-transformer 规则:
import { instanceToPlain } from 'class-transformer'
describe('UserResponseDto serialization', () => {
it('should hide password and format createdAt', () => {
const user = Object.assign(new UserResponseDto(), {
id: 1,
username: 'admin',
password: 'secret',
createdAt: new Date('2026-07-20T02:30:00.000Z'),
})
expect(instanceToPlain(user)).toMatchObject({
id: 1,
username: 'admin',
})
expect(instanceToPlain(user)).not.toHaveProperty('password')
})
})
更重要的是增加 e2e 测试,因为它能够同时验证全局拦截器、Controller 返回类型和 JSON 响应:
it('GET /user should serialize response', () => {
return request(app.getHttpServer())
.get('/user')
.expect(200)
.expect((response) => {
expect(response.body[0].createdAt).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
)
expect(response.body[0]).not.toHaveProperty('password')
})
})
测试日期时要固定时区或使用明确时区的预期值,否则本地和 CI 环境可能得到不同结果。
十七、推荐落地方案
对于使用 NestJS 和 TypeORM 的常规项目,可以按以下顺序落地:
- 在根模块全局注册
ClassSerializerInterceptor。 - 在通用实体基类中处理
createdAt、updatedAt等全局一致字段。 - 对
password、密钥、内部备注等字段使用@Exclude({ toPlainOnly: true }),同时从查询层避免读取。 - 对原生 SQL 和外部数据使用
@SerializeOptions({ type })或plainToInstance()创建真实实例。 - 对公开或复杂接口建立独立 Response DTO,并采用
excludeAll或excludeExtraneousValues白名单策略。 - 用 e2e 测试锁定最终 JSON,而不只测试 Service 返回值。
- 明确全局日期与时区规范,避免只统一“外观”却没有统一时间语义。
小结
class-transformer 的价值不只是把日期换一种字符串格式,而是让响应规则从命令式的手工删改,变为集中、声明式、可测试的 API 契约。
本文的基础改造只需要两步:全局注册 ClassSerializerInterceptor,然后在实体字段上添加带有 toPlainOnly 的 @Transform()。在此基础上,还可以使用 @Exclude() 保护敏感字段、用 @Expose() 重命名或生成字段、用 groups 和 version 管理差异,并通过 Response DTO 与白名单策略建立更稳定的接口边界。
最后要始终记住:装饰器声明在类上,能否正确序列化取决于运行时返回值是不是对应的类实例。遇到规则不生效时,优先检查返回对象的构造函数、拦截器是否注册,以及是否使用原生响应对象绕过了 NestJS 响应链。