跳转到内容

验证管道 (Zod)

基于 Zod 的请求数据验证管道,Schema 即验证规则,自动推断 TypeScript 类型。

ZodPipe

参数级使用(推荐)

使用 Zod() 工厂函数搭配 NestJS 原生 @Body / @Query / @Param 装饰器:

typescript
import { z } from 'zod'
import { Zod, zStr, zStrReq, zInt } from '@maxtan/nest-core'

// 定义 Schema
const CreateUserSchema = z.object({
  username: zStr.pipe(z.string().min(2).max(20)),
  email: z.string().email(),
  age: zInt.pipe(z.number().int().min(1).max(150).optional())
})

type CreateUser = z.infer<typeof CreateUserSchema>

@Controller('users')
export class UserController {
  @Post()
  create(@Body(Zod(CreateUserSchema)) dto: CreateUser) {
    // dto 已验证、已推断类型
    return this.userService.create(dto)
  }

  @Get()
  list(@Query(Zod(ListSchema)) query: ListDto) {
    return this.userService.list(query)
  }
}

Zod(schema) 等价于 new ZodPipe(schema) 的简写形式。

ZodPipe 本身只负责 safeParse 和错误转换,不会默认对所有字符串做全局空串归一化。空串转 undefined / null 这类语义,应当由字段级 helper 显式表达。

全局注册

typescript
import { APP_PIPE } from '@nestjs/core'
import { ZodPipe } from '@maxtan/nest-core'

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

全局注册时 ZodPipe 不绑定 Schema,不会对无 Schema 的参数做验证。推荐在参数级传入具体 Schema。

配置选项

typescript
Zod(schema, {
  showAllErrors: true,    // 返回所有错误(默认 false,只返回第一条)
  errorSeparator: ' | '   // 错误分隔符(默认 '; ')
})
选项类型默认值说明
showAllErrorsbooleanfalse是否返回所有验证错误
errorSeparatorstring'; '多条错误的分隔符

错误响应示例

管道内置中文错误翻译,无需在 Schema 里手写错误信息:

json
{
  "code": 400,
  "message": "username: 最少2个字符",
  "success": false,
  "timestamp": 1700000000000
}

显示所有错误时:

json
{
  "message": "username: 最少2个字符; email: 邮箱格式不正确"
}

请求参数为空时:

json
{
  "code": 400,
  "message": "请求参数不能为空",
  "success": false
}

内置错误翻译对照:

场景提示
字段未传username: 必填
字符串长度username: 最少2个字符 / 最多20个字符
数字范围age: 最小值1 / 最大值150
数组长度tags: 最少1项 / 最多10项
邮箱格式email: 邮箱格式不正确
URL 格式website: URL 格式不正确
枚举值role: 值必须是 admin | user 之一

如需自定义错误信息,仍可在 Schema 里传入:z.string().min(2, '用户名太短了')


Zod 预处理器

提供了一组 Zod 预处理器,用于自动转换前端传来的字符串参数。

基础预处理器默认选填(输出 T | undefined),通过 .pipe() 追加必填或约束规则。高频场景提供了 Req 简写。

Prisma 对齐辅助器默认必填(对齐 Prisma 非空列),通过 .optional() 变选填。

字符串字段现在推荐优先按语义选 helper,而不是直接写原生 z.string()

  • 普通可选文本:zStr
  • 必填文本:zStrReq
  • 可空文本:zStrNull
  • 可空 VarChar 列:zVarCharNull(max, min?)
  • 字符串数组:zStrArr(options?)

预处理器一览

分类预处理器输出类型说明
字符串zStrstring?trim + 空串→undefined
zStrReqstringtrim 后必填字符串
zStrNullstring | null | undefinedtrim + 空串→null
zVarChar(max, min?)string对齐 Prisma @db.VarChar(n)
zVarCharNull(max, min?)string | null | undefined对齐可空 VarChar,空串→null
数字zNum()number?安全数字转换(含小数)
zNum(true) / zIntnumber?安全整数转换(推荐用 zInt
zNumReqnumber= zNum().pipe(z.number()),必填数字
zIntReqnumber= zInt.pipe(z.number().int()),必填整数
枚举zEnumInt(...values)T对齐 Prisma @db.TinyInt,必填
zEnumStr(...values)T对齐 Prisma enum,必填
查询zQueryInt(options?)number?查询整数,空串→undefined,非法值报错
zQueryId(options?)string?查询 ID,空串→undefined
zQueryEnumInt(...values)T?查询整数枚举,支持枚举对象
zQueryEnumStr(...values)T?查询字符串枚举,支持枚举对象
布尔zBoolboolean?智能布尔转换
数组zStrArr(options?)string[]?字符串数组逐项 trim,默认过滤空串
zArrany[]?通用逗号分隔拆数组(自定义用 zArrOf()
日期zDateDate?字符串/时间戳→Date
zDateReqDate= zDate.pipe(z.date()),必填日期

zStr — 可选字符串

去除首尾空格,空字符串转为 undefined

typescript
import { zStr, zStrReq } from '@maxtan/nest-core'

const schema = z.object({
  nickname: zStr,                               // 选填
  bio: zStr,
  name: zStrReq,                                // 必填(无约束)
  code: zStrReq,
  username: zStr.pipe(z.string().min(2)),
})

// '  hello  ' → 'hello'
// '  '        → undefined

推荐场景:

  • 搜索关键词、备注、标题等普通可选文本
  • Update DTO 里“不传就不更新”的字符串字段
  • 需要统一 trim,但不希望空串落库的字段

不建议用于:

  • 业务上明确需要保留空串的字段
  • 前端传空串表示“清空为 null”的字段

zStrNull / zVarCharNull — 可空字符串

当数据库列允许 NULL,且前端常用空串表示“清空字段”时,使用 zStrNullzVarCharNull

typescript
import { z } from 'zod'
import { zStrNull, zVarCharNull } from '@maxtan/nest-core'

const UpdateProfile = z.object({
  intro: zStrNull,
  nickname: zVarCharNull(50, 2)
})

// { intro: '  hi  ' } -> { intro: 'hi' }
// { intro: '   ' }    -> { intro: null }
// { nickname: '' }    -> { nickname: null }
// { nickname: 'a' }   -> 报错(最少 2 个字符)

选型建议:

  • 只需要“trim + 空串转 null”:用 zStrNull
  • 还要对齐 @db.VarChar(n) 长度:用 zVarCharNull(max, min?)
  • 如果空串应该表示“不传”,仍然用 zStr

zVarChar — 对齐 Prisma VarChar

自动 trim + 必填 + 长度约束,直接对齐 Prisma schema 列定义:

typescript
import { zVarChar } from '@maxtan/nest-core'

// schema.prisma:  username String @db.VarChar(50)
const username = zVarChar(50, 2)               // 必填 + min(2) + max(50)

// schema.prisma:  nickname String? @db.VarChar(50)
const nickname = zVarChar(50).optional()        // .optional() 对齐 Prisma ?

// schema.prisma:  remark String? @db.VarChar(500)
const remark = zVarChar(500).optional()

如果字段是可空列,且前端空串需要转成 null,不要再写 z.string().nullable().optional(),直接改用:

typescript
const intro = zVarCharNull(500)

zNum / zInt — 安全数字转换

typescript
import { zNum, zInt, zNumReq, zIntReq } from '@maxtan/nest-core'

const schema = z.object({
  price: zNum(),                                          // 选填浮点数
  amount: zNumReq,                                        // 必填浮点数
  page: zInt.pipe(z.number().int().min(1).default(1)),    // 选填整数 + 默认值
  age: zIntReq,                                           // 必填整数
  status: zInt.pipe(z.number().int().min(0).max(1).optional()), // 选填整数 + 约束
})

// '' → undefined(不会转为 0)
// 'abc' → undefined
// '99.9' → 99.9(zNum)/ 99(zInt)

zInt 等价于 zNum(true),推荐使用 zInt,更简洁。

zQueryInt / zQueryId — 查询参数专用基础类型

这两个 helper 会把 undefinednull、空串、空白串都视为“未传”,但不会把其它非法值静默吞成 undefined

typescript
import { zQueryId, zQueryInt } from '@maxtan/nest-core'

const ListRoomSchema = z.object({
  buildingNo: zQueryInt({ min: 1 }),
  month: zQueryInt({ min: 1, max: 12 }),
  ownerId: zQueryId(),
  roleId: zQueryId(64)
})

// null / '' / '   ' -> undefined
// '12' -> 12
// 'abc' -> 报错
// true / {} -> 报错

适用场景:

  • 后台列表筛选中的楼栋编号、月份、类型、状态等整数条件
  • 关联对象筛选中的 roleIdownerIdgroupId 之类可选 ID
  • 前端表单初始值常用 null,但后端语义是“不传就不过滤”

不建议用于:

  • @Param('id') 这类必填路径参数
  • 创建、更新接口中的实体字段写入
  • 需要把 null 当作真实业务值的场景

zEnumInt / zEnumStr — 对齐 Prisma 枚举

类型安全的枚举校验,返回必填 schema,查询/更新场景按需 .optional()

typescript
import { zEnumInt, zEnumStr } from '@maxtan/nest-core'

// 对齐 Prisma @db.TinyInt 离散值
const status = zEnumInt(0, 1)                  // 必填,类型 0 | 1
const type = zEnumInt(1, 2, 3)                 // 必填,类型 1 | 2 | 3

// 对齐 Prisma enum
const role = zEnumStr('ADMIN', 'USER', 'GUEST')  // 必填,类型 'ADMIN' | 'USER' | 'GUEST'

// 在 Schema 中按需可选
const ListSchema = zPage().extend({
  status: status.optional(),                   // 查询过滤 → 选填
  role: role.optional()                        // 查询过滤 → 选填
})

const CreateSchema = z.object({
  status,                                      // 创建时必填
  role: role.optional()                        // DB 有默认值 → 选填
})

推荐搭配 as const 枚举常量使用,参见下方 Prisma 集成模式

zQueryEnumInt / zQueryEnumStr — 查询枚举过滤

用于列表查询、筛选条件、统计接口过滤项。与 zEnumInt / zEnumStr 的区别是:null 和空串都会视为未传,不参与过滤。

typescript
import { zQueryEnumInt, zQueryEnumStr, zPage } from '@maxtan/nest-core'

export const UserStatus = { DISABLED: 0, ENABLED: 1 } as const
export const UserRole = { ADMIN: 'ADMIN', USER: 'USER', GUEST: 'GUEST' } as const

const ListUserSchema = zPage().extend({
  status: zQueryEnumInt(UserStatus),
  role: zQueryEnumStr(UserRole)
})

// { status: null, role: '   ' } -> { status: undefined, role: undefined }
// { status: '1', role: 'ADMIN' } -> { status: 1, role: 'ADMIN' }
// { status: '3' } -> 报错
// { role: 'ROOT' } -> 报错

如果只是创建、更新或命令式接口里的字段写入,仍然使用 zEnumInt / zEnumStr

典型应用场景:

  • 用户列表中的 statusrole 下拉筛选
  • 订单列表中的 typesourcepayStatus 过滤
  • 统计接口中的离散条件过滤,例如“按状态统计”“按角色统计”

不建议用于:

  • 创建接口里“字段必须是合法枚举”的场景
  • 更新接口里“空值必须报错”的场景
  • 需要把空字符串识别成非法输入的命令式接口

写入字段 vs 查询过滤

同样是枚举或整数字段,写入场景和查询场景的语义并不相同。推荐按下面的边界使用:

维度写入字段查询过滤
推荐 helperzEnumInt / zEnumStr / zIntReq / zIdzQueryEnumInt / zQueryEnumStr / zQueryInt / zQueryId
典型位置@Body() 创建、更新 DTO@Query() 列表、筛选、统计 DTO
''非法输入视为未传
null非法输入视为未传
非法值报错报错
目标语义保证字段值有效保证过滤条件有效,同时允许“不筛选”

可以直接按这个判断:

  • 这个字段最终会写进数据库列:优先用写入 helper
  • 这个字段只是决定要不要加 where 条件:优先用 query helper
  • 如果前端常给下拉框初始值 null'':应该用 query helper,而不是在业务层手动兼容

选型示例

typescript
import { z } from 'zod'
import {
  zId,
  zEnumInt,
  zQueryId,
  zQueryEnumInt,
  zPageWithKeyword
} from '@maxtan/nest-core'

const STATUS_VALUES = [0, 1] as const

// 写入:字段要落库,非法值必须报错
export const CreateUser = z.object({
  managerId: zId,
  status: zEnumInt(...STATUS_VALUES)
})

// 查询:没选状态或负责人时,不加过滤条件
export const ListUser = zPageWithKeyword().extend({
  managerId: zQueryId(),
  status: zQueryEnumInt(...STATUS_VALUES)
})

上面这组写法的区别是:

  • CreateUser.status = '' 会直接报错
  • ListUser.status = '' 会被当成未传,不参与过滤
  • CreateUser.managerId = null 会报错
  • ListUser.managerId = null 会被当成未传

zBool — 智能布尔值转换

typescript
import { zBool } from '@maxtan/nest-core'

const schema = z.object({
  isActive: zBool        // 直接用,已是 boolean | undefined
})

// 'true' / '1' / 'yes' → true
// 'false' / '0' / 'no' / '' → false
// null / undefined → undefined

zStrArr — 字符串数组专用 helper

相比通用 zArr,这个 helper 会对数组内每个字符串元素执行 trim,并且默认过滤空串:

typescript
import { z } from 'zod'
import { zStrArr } from '@maxtan/nest-core'

const schema = z.object({
  tags: zStrArr({ min: 1 }),
  userIds: zStrArr({ unique: true })
})

// [' a ', ' ', 'b'] -> ['a', 'b']
// 'a, ,b,a' -> ['a', 'b', 'a']
// zStrArr({ unique: true }) -> ['a', 'b']

推荐场景:

  • 标签列表、用户 ID 列表、人群值列表
  • 既可能传数组,也可能传逗号分隔字符串的接口
  • 需要消除 ['', ' '] 这类无效元素的场景

常用选项:

选项类型默认值说明
separatorstring','字符串输入时的分隔符
uniquebooleanfalse是否去重
filterEmptybooleantrue是否过滤空串元素
minnumber-最少保留元素个数
maxnumber-最多保留元素个数

zArr — 通用数组预处理器

typescript
import { zArr, zArrOf } from '@maxtan/nest-core'

const schema = z.object({
  tags: zArr,                                         // 逗号分隔(常量)
  ids: zArrOf({ separator: '|', unique: true })        // 自定义分隔符 + 去重
})

// 'a,b,c' → ['a', 'b', 'c']
// 'a|b|a' → ['a', 'b'](unique: true)

zDate — 日期转换

typescript
import { zDate, zDateReq } from '@maxtan/nest-core'

const schema = z.object({
  startDate: zDate,      // 选填
  birthday: zDateReq,    // 必填
})

// '2025-01-01'     → Date
// 1704067200000    → Date(13 位时间戳)
// 1704067200       → Date(10 位,自动 ×1000)
// '1704067200000'  → Date(字符串时间戳)

zPage — 分页查询

内置分页 Schema 工厂函数,自动处理字符串到整数的转换和默认值。支持自定义默认配置。

typescript
import { z } from 'zod'
import { zPage, zStr, Zod } from '@maxtan/nest-core'
import type { ZPage } from '@maxtan/nest-core'

// 默认配置:current=1, size=20
const ListUserSchema = zPage().extend({
  keyword: zStr,
  status: zEnumInt(0, 1).optional()
})

type ListUser = z.infer<typeof ListUserSchema>

@Controller('users')
export class UserController {
  @Get()
  list(@Query(Zod(ListUserSchema)) query: ListUser) {
    // query.current: number (默认 1)
    // query.size: number (默认 20)
    return this.userService.list(query)
  }
}

自定义分页默认值

typescript
// 日志查询:默认每页 50 条,最多 500 条
const ListLogSchema = zPage({ defaultSize: 50, maxSize: 500 }).extend({
  level: zStr,
  startDate: zDate
})

// 后台管理:默认每页 10 条
const ListOrderSchema = zPage({ defaultSize: 10 }).extend({
  status: zInt
})

配置选项

参数类型默认值说明
defaultCurrentnumber1默认页码
defaultSizenumber20默认每页数量
maxSizenumber2000每页最大数量

Schema 复用组合器

为了降低业务侧 Schema 手工成本,推荐优先使用以下组合器:

组合器说明典型场景
zId通用字符串 ID@Param('id')、删除/详情接口
zQueryId()可选 ID 查询条件列表筛选、关联对象过滤
zIdsID 数组(支持逐项 trim + 空串过滤)批量删除、批量查询
zQueryInt()可选整数查询条件状态、月份、楼栋编号
zPageWithKeyword()分页 + keyword 的列表查询基座后台列表页
zDateRange()开始/结束时间范围(内置先后校验)日志、审计、报表查询

zId / zIds 示例

typescript
import { z } from 'zod'
import { zId, zIds } from '@maxtan/nest-core'

const DeleteSchema = z.object({ id: zId })
const BatchDeleteSchema = z.object({ ids: zIds })

// ids 支持:
// 'a,b,c' -> ['a', 'b', 'c']
// [' a ', ' ', 'b'] -> ['a', 'b']

zPageWithKeyword 示例

typescript
import { zPageWithKeyword, zQueryEnumInt } from '@maxtan/nest-core'

const ListUser = zPageWithKeyword().extend({
  status: zQueryEnumInt(0, 1)
})

// 自定义:关键词字段名 + 分页默认值
const ListOrder = zPageWithKeyword({
  keywordKey: 'q',
  keywordMaxLength: 120,
  defaultCurrent: 1,
  defaultSize: 50,
  maxSize: 500
})

zPageWithKeyword(options) 选项:

参数类型默认值说明
keywordKeystring'keyword'关键词字段名
keywordMaxLengthnumber100关键词最大长度
defaultCurrentnumber1默认页码(继承 zPage
defaultSizenumber20默认每页数量(继承 zPage
maxSizenumber2000每页最大数量(继承 zPage

zDateRange 示例

typescript
import { zPage, zDateRange } from '@maxtan/nest-core'

const ListLog = zPage().merge(zDateRange())
// 默认字段:startAt / endAt
// 自动校验:startAt <= endAt

// 自定义字段名
const ListAudit = zPage().merge(
  zDateRange({
    startKey: 'beginAt',
    endKey: 'finishAt'
  })
)

zDateRange(options) 选项:

参数类型默认值说明
startKeystring'startAt'开始时间字段名
endKeystring'endAt'结束时间字段名
validateOrderbooleantrue是否校验开始时间不晚于结束时间

zPage() 生成的字段:

字段类型默认值范围
currentnumberdefaultCurrent>= 1,整数
sizenumberdefaultSize1 ~ maxSize,整数

Prisma 集成模式

推荐的 Zod Schema 与 Prisma Schema 对齐模式:字段约束定义一次,Schema 按需组合

1. 定义枚举常量

使用 as const 对象作为枚举值的单一来源,Schema 和业务逻辑共享:

typescript
/** 用户状态:0-禁用 1-启用(对应 @db.TinyInt) */
export const UserStatus = { DISABLED: 0, ENABLED: 1 } as const
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus] // 0 | 1

/** 用户角色(对应 Prisma enum Role) */
export const UserRole = { ADMIN: 'ADMIN', USER: 'USER', GUEST: 'GUEST' } as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]

2. 对齐 Prisma 列定义

每个字段约束只定义一次,注释标注对应的 Prisma 列:

typescript
// ─── Prisma User 模型字段约束 ───
// 规则:非空列 → 必填 schema;可空列(Prisma ?)→ .optional()

/** username String @unique @db.VarChar(50) */
const username = zVarChar(50, 2)

/** password String @db.VarChar(255) */
const password = z.string().min(6).max(50)

/** nickname String? @db.VarChar(50) — 可空列 */
const nickname = zVarChar(50).optional()

/** status Int @default(1) @db.TinyInt — 非空(有默认值) */
const status = zEnumInt(UserStatus.DISABLED, UserStatus.ENABLED)

/** role Role @default(USER) — 非空(有默认值) */
const role = zEnumStr(UserRole.ADMIN, UserRole.USER, UserRole.GUEST)

3. 组合 Schema

Schema 层根据场景(查询/创建/更新)决定字段的必填/选填:

typescript
/** 列表查询:所有过滤条件都是选填 */
export const ListUser = zPage().extend({
  keyword: zStr,
  status: zQueryEnumInt(UserStatus),
  role: zQueryEnumStr(UserRole)
})

/** 创建:必填字段直接引用,有 DB 默认值的加 .optional() */
export const CreateUser = z.object({
  username,                        // 必填
  password,                        // 必填
  nickname,                        // 已在字段定义处标记 .optional()
  role: role.optional()            // DB 有 @default(USER) → 选填
})

/** 更新:基于 CreateUser 全部可选 + 扩展更新独有字段 */
export const UpdateUser = CreateUser.partial().extend({
  status: status.optional()
})

设计原则

原则说明
字段 = 列字段约束对齐 Prisma 列:zVarChar(50)@db.VarChar(50)
非空 = 必填Prisma 非空列 → 必填 schema,可空列(?)→ .optional()
Schema = 场景同一字段在不同 Schema 中按需 .optional(),无需重复定义约束
枚举 = 常量as const 对象同时给 Schema 校验和业务逻辑使用

使用规则速查

场景写法说明
选填字符串zStrtrim + 空串 -> undefined
必填字符串zStrReq无约束时的简写
可空字符串zStrNulltrim + 空串 -> null
必填 + 约束zStr.pipe(z.string().min(2).max(50))不需要写中文 message
Prisma VarCharzVarChar(50, 2)必填,.optional() 变选填
可空 VarCharzVarCharNull(50, 2)可空列,空串表达清空
选填整数zInt等价于 zNum(true)
查询整数zQueryInt({ min: 1 })null / 空串不过滤,非法值报错
必填整数zIntReq无约束时的简写
选填整数 + 约束zInt.pipe(z.number().int().min(0).max(1).optional()).optional() 放 pipe 内
必填数字zNumReq含小数
整数枚举zEnumInt(0, 1)必填,.optional() 变选填
查询整数枚举zQueryEnumInt(UserStatus)查询过滤专用,支持枚举对象
字符串枚举zEnumStr('A', 'B')必填,.optional() 变选填
查询字符串枚举zQueryEnumStr(UserRole)查询过滤专用,支持枚举对象
查询 IDzQueryId(64)null / 空串不过滤,长度可限制
选填布尔zBool直接用
选填日期zDate直接用
必填日期zDateReq简写
字符串数组zStrArr({ min: 1 })逐项 trim,默认过滤空串
选填数组zArr自定义用 zArrOf(options)
分页查询zPage().extend({ ... })支持自定义默认值

列表查询场景推荐优先组合:

  • 分页:zPage()zPageWithKeyword()
  • 可选整数:zQueryInt()
  • 可选 ID:zQueryId()
  • 可选枚举:zQueryEnumInt() / zQueryEnumStr()
  • 时间范围:zDateRange()

对象清理边界:

  • cleanObject 适合在 service/query 层移除 nullundefined、空串等无效值
  • cleanObject 不负责深度字符串语义归一化,不等价于“递归 trim 所有 JSON 字符串”
  • 字符串字段的语义仍应由 zStrzStrNullzVarCharNullzStrArr 这类 helper 显式表达

辅助工具 getZodError

ZodError 中提取格式化的错误信息:

typescript
import { getZodError } from '@maxtan/nest-core'

const result = schema.safeParse(data)
if (!result.success) {
  const msg = getZodError(result.error)         // 只取第一条
  const all = getZodError(result.error, true)    // 所有错误,用 '; ' 分隔
  const custom = getZodError(result.error, true, ' | ')  // 自定义分隔符
}

相关文档