NestJS Zod 理论上是可以成为一层真正的契约系统的:请求校验、类型推导、OpenAPI 生成、领域约束复用,前后端一致的输入语义,都在同一份定义里。

领域约束是否要复用需要讨论,但只说技术上可行。

最简单的用法是用 z.object(...) 定义 schema,用 createZodDto(schema) 生成 DTO,然后引入一个全局的 ZodValidationPipe。大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod';

@Module({
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe
}
]
})
export class AppModule {}

DTO 只是 schema 的包装:

1
2
3
4
5
6
7
8
9
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';

const createItemSchema = z.object({
name: z.string().trim().min(1),
price: z.number().nonnegative()
});

export class CreateItemDto extends createZodDto(createItemSchema) {}

这样运行时校验和 TypeScript 类型就共享同一份定义了。


Zod 可以很简单的实现字符串非空、数字范围、枚举取值、对象结构、Discriminated Union。DU 比传统 DTO 写法强,因为不同 type 对应的字段集合可以天然收敛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const requestSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('A'),
payload: z.object({
foo: z.string()
})
}),
z.object({
type: z.literal('B'),
payload: z.object({
bar: z.number()
})
})
]);

但真实业务的问题通常不止于单字段合法。我遇到的一些情况有:

  • 某个数组元素本身没问题,但数组整体不能重复
  • 某个字段语法上合法,但必须属于当前 recordType 允许的字段集合
  • 两个字段分别没问题,但组合起来语义冲突

这类规则会比较严重的影响接口可维护性,它们的特点是 "结构正确,语义错误”,假设请求结构是这样的:

1
2
3
4
5
6
7
8
{
recordType: 'A',
structuredData: { ... },
fieldExceptions: [
{ fieldKey: 'x' },
{ fieldKey: 'y' }
]
}

此处 fieldExceptions 的意思是 “标记 structuredData 中的某个字段在业务上的特殊情况”,例如在数据标注系统中,数据标注人员无法结构化录入某个字段。

单看每一项 { fieldKey: z.string(), reason?: z.string() } 完全合法。但可能出现这种情况:

1
2
3
4
fieldExceptions: [
{ fieldKey: 'x' },
{ fieldKey: 'x' }
]

JSON 结构没问题,类型也没问题,但业务语义上明显重复了。NestJS Zod 处理这类问题的方法是直接在 schema 层面操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function withUniqueFieldExceptions<T extends z.ZodTypeAny>(schema: T) {
return schema.superRefine((value, ctx) => {
const fieldExceptions = (value as {
fieldExceptions?: Array<{ fieldKey: string }>;
}).fieldExceptions;

if (!fieldExceptions) return;

const seen = new Set<string>();

for (const [index, item] of fieldExceptions.entries()) {
if (seen.has(item.fieldKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate field exception for ${item.fieldKey}.`,
path: ['fieldExceptions', index, 'fieldKey']
});
}

seen.add(item.fieldKey);
}
});
}

如果把去重校验写在 service/command/query 中,系统中的其他任何组件(例如测试)都可能绕过去。但如果写在 schema 中,所有入口只要用的是这份 schema,就天然继承这个约束。

注意在这个 helper 中有一个细节:错误路径要详细到具体数组项。path: ['fieldExceptions', index, 'fieldKey'] 这个写法可以让前端拿到错误后不只是知道请求失败,而是能知道哪一项重复/错误。对于复杂表单,这种可定位性比一条顶层报错有用。

另外我这里给出的 helper 主要向展示的是 schema 层面的约束能力,不是业务对象, withUniqueFieldExceptions 不是某个接口私有逻辑,而是一种可复用的 schema 装饰器模式。同理可以有 withUniqueAttachmentChangeswithNonOverlappingRangeswithConsistentDateOrder。这类 helper 一旦抽出来,schema 层就具备组合能力,业务约束可以像搭积木一样拼装。

沿着这个思路,我们可以进一步的表示 “fieldExceptions[].fieldKey 不应该是任意字符串,它必须属于当前 recordType 允许的字段集合”:

1
2
3
4
5
6
function createFieldExceptionSchema(fieldKeys: [string, ...string[]]) {
return z.object({
fieldKey: z.enum(fieldKeys),
reason: z.string().trim().max(500).optional()
});
}

然后在 discriminatedUnion 的每个分支里绑定自己的字段白名单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const requestSchema = z.discriminatedUnion('recordType', [
withUniqueFieldExceptions(
z.object({
recordType: z.literal('A'),
structuredData: z.object({
foo: z.union([z.number(), z.string(), z.null()])
}).strict(),
fieldExceptions: z.array(createFieldExceptionSchema(['foo'])).default([])
})
),
withUniqueFieldExceptions(
z.object({
recordType: z.literal('B'),
structuredData: z.object({
bar: z.union([z.number(), z.string(), z.null()])
}).strict(),
fieldExceptions: z.array(createFieldExceptionSchema(['bar'])).default([])
})
)
]);

这样 recordType 和合法字段天然绑定,不需要额外写一堆 if/else,错误会在 parse 阶段就暴露而不是进入业务流程后才抛异常。schema 在这里承担了类型分支的职责。


但对于复杂输入的场景,需要注意分层,validator 和 normalizer 应该分开。schema 不一定负责把所有值变干净,但它应该负责把输入约束在一个可以被 normalizer 处理的范围内。比如数值输入,前端可能传 12.3"12.3"null,schema 可以先允许 z.union([z.number(), z.string(), z.null()]),parse 成功之后再进入统一的 normalizer。这种分层比在 schema 里直接做满所有 coercion 会更加可维护,因为它把两个问题拆开了:schema 负责数据能不能被系统处理,normalizer 负责进来的数据如何变成领域标准格式。这能避免 schema 逐渐膨胀成一个难以维护的黑盒。

运行时 schema 和文档 schema 可以适度分离。有些真实运行时 schema 很复杂,比如 DU、superRefine、动态字段白名单等,有些约束还依赖运行时上下文,这些对 OpenAPI JSON Spec 的生成不一定友好。可以考虑保留两套:requestSchema 真实运行时校验用,requestDtoSchema 专门服务 OpenAPI 的描述性 schema。controller 的 DTO 从 requestDtoSchema 生成,但真正执行业务前,再对原始 payload 走一次 requestSchema.safeParse(...)

文档可读性和运行时严谨性不必强行绑定在同一份 schema 表达能力上。如果一份 schema 同时满足两者当然最好,如果不能,优先保证运行时正确性。

注意,虽然 Controller 已经有 ZodValidationPipe,但如果用 CQRS,那么在 command/query handler 最好还要再次 safeParse 一下,因为进入 handler 的 payload 未必只来自 HTTP,还可能来自 cron、queue consumer、internal dispatch、test fixture、script。如果 handler 是真正的业务入口,那它就应该自己守住边界。这不是重复,而是分层后更加清晰的职责,因为 Pipe 只保护 HTTP 入口,handler 内 safeParse 主要保护业务入口。

最后,直接对 schema 写测试:

1
2
3
4
5
6
7
8
9
10
11
12
it('rejects duplicated field exceptions', () => {
const result = requestSchema.safeParse({
recordType: 'A',
structuredData: { foo: '1' },
fieldExceptions: [
{ fieldKey: 'foo' },
{ fieldKey: 'foo' }
]
});

expect(result.success).toBe(false);
});

这种测试验证的是契约本身,不是某个 service 逻辑的分支,这种测试看起来更有价值也更清晰。