DTO与Schema分层
要点
- 把数据库模型直接当 API 输入输出类型用,字段变更时会同时波及存储层和接口层
- DTO(Data Transfer Object)是分层边界上的数据载体,负责隔离内部结构和外部契约
- Input DTO 和 Output DTO 字段不同、职责不同,不应该共用同一个类型
- 用 Zod 的
.pick()、.omit()、.extend()可以从一个 base schema 派生出一组分层 schema z.infer适合从 schema 推导类型;只有当校验逻辑和类型声明需要解耦时,才考虑手写 interface- 响应里过滤敏感字段(密码哈希、内部状态标记)是输出 DTO 的基本职责
- Schema 分层让数据库模型和接口格式各自独立演进,mapper 层负责中间的转换
1. 问题的起点:数据库模型通吃一切
很多项目在起步阶段会这样写:
// src/db/models.ts
import { z } from 'zod'
export const User = z.object({
id: z.number(),
email: z.string().email(),
name: z.string().min(1),
passwordHash: z.string(),
role: z.enum(['admin', 'user']),
createdAt: z.date(),
updatedAt: z.date(),
})
type User = z.infer<typeof User>注册接口直接拿这个 model 做输入校验,返回用户列表时也用同一个类型。早期可以跑通,但用一段时间之后,几组矛盾会逐渐显现:
- 注册接口不需要
id、createdAt、updatedAt——这些由服务端生成,客户端传了也没用,但 schema 要求它们必须存在 - 返回用户信息时,
passwordHash被一起返回——接口和数据库共用同一个类型,字段增减会同时影响两端 - 更新接口只需要部分字段——用户改昵称不需要传
email和role,但Userschema 把它们全标成 required - 不同接口对同一实体的字段需求不同——列表页只要
id、name,详情页多几个字段,但它们绑定在同一个类型上
这些矛盾有一个共同的根源:存储层的类型和传输层的类型混在了一起。数据库模型描述的是「数据在磁盘上长什么样」,API 类型描述的是「客户端和服务端之间交换什么数据」。两者的生命周期、变更频率和受众都不一样。
2. DTO 是什么
DTO(Data Transfer Object)在 TypeScript / Hono 语境里可以理解为:接口边界上的数据结构,专门描述一次 API 请求或响应中实际传输的字段。
| 维度 | 数据库模型 | DTO |
|---|---|---|
| 描述对象 | 数据在存储层的结构 | 数据在接口层的结构 |
| 变更原因 | 存储需求变化 | 接口契约变化 |
| 字段范围 | 包含所有持久化字段 | 只包含当前接口需要的字段 |
| 敏感字段 | 保留密码哈希、内部标记 | 输出 DTO 过滤掉不该暴露的字段 |
| 归属 | 存储层 / Repository | 接口层 / Route |
DTO 的价值在于提供缓冲层。接口可以增删字段、调整格式,只要存储层到 DTO 的映射逻辑跟着改,数据库模型不需要动。反过来,数据库加了一列也不一定影响已有接口——除非新列需要暴露给客户端。
3. Input DTO 与 Output DTO
同一个实体的输入和输出,字段组成通常不同。以用户注册为例:
Input DTO(注册请求)——客户端需要提供的字段:
// src/features/users/dto.ts
import { z } from 'zod'
export const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(1).max(50),
password: z.string().min(8),
})
export type CreateUserInput = z.infer<typeof CreateUserInput>Output DTO(注册响应)——服务端返回给客户端的字段:
// src/features/users/dto.ts
export const UserOutput = z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'user']),
createdAt: z.string(), // ISO 日期字符串,不是 Date 对象
})
export type UserOutput = z.infer<typeof UserOutput>两者的差异:
- 输入有
password,输出没有——密码不需要回传给客户端 - 输出有
id、role、createdAt,输入没有——这些由服务端生成 - 输出里的
createdAt是字符串,数据库里是Date对象——传输格式和存储格式不同
注册接口的代码因此可以写成:
// src/routes/users.ts
app.post('/users', zValidator('json', CreateUserInput), async (c) => {
const input = c.req.valid('json')
const user = await userService.create(input)
const output: UserOutput = toUserOutput(user)
return c.json(output, 201)
})输入用 CreateUserInput 校验请求,输出用 UserOutput 约束返回值。两条线各自独立,改一边不影响另一边。
4. Schema 分层策略
当实体和接口增多之后,schema 之间的重复会上升。一种比较稳定的分层方式是:base schema → create schema → update schema → response schema。
// src/features/users/schemas.ts
import { z } from 'zod'
// Base:核心字段,不包含「谁负责生成」的信息
export const UserBase = z.object({
email: z.string().email(),
name: z.string().min(1).max(50),
role: z.enum(['admin', 'user']).default('user'),
})
// Create:base + 创建时独有的字段
export const CreateUser = UserBase.extend({
password: z.string().min(8),
})
// Update:所有字段可选,只传需要改的
export const UpdateUser = UserBase.partial()
// Response:base + 服务端生成的字段
export const UserResponse = UserBase.extend({
id: z.number(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})四个 schema 之间的关系:
UserBase (核心字段)
├── CreateUser (base + password)
├── UpdateUser (base.partial())
└── UserResponse (base + id + timestamps)
这种分层的核心思路是:字段定义只写一次,派生 schema 通过组合方法从 base 生成,避免重复。
5. Zod 的 schema 组合工具
上面的分层依赖 Zod 提供的几个组合方法。逐个拆开看:
.extend() — 追加字段。新字段和原字段同名时,新定义覆盖原定义:
// src/schemas/example.ts
const Base = z.object({ name: z.string(), email: z.string().email() })
const Extended = Base.extend({ age: z.number().int().positive() })
// { name: string; email: string; age: number }.omit() — 移除字段。常见用途是从完整 schema 派生出精简版本:
// src/schemas/example.ts
const FullUser = z.object({
id: z.number(), name: z.string(), email: z.string(),
passwordHash: z.string(), role: z.string(),
})
const UserListItem = FullUser.omit({ email: true, passwordHash: true })
// { id: number; name: string; role: string }.pick() — 选取字段,和 .omit() 方向相反:
// src/schemas/example.ts
const UserContact = FullUser.pick({ name: true, email: true })
// { name: string; email: string }.partial() — 全部可选,适合 update 接口。也可以只对部分字段生效:
// src/schemas/example.ts
const AllOptional = Base.partial() // 全部可选
const NameOptional = Base.partial({ name: true }) // 只有 name 可选.merge() — 合并两个 schema,适合字段来源不同的场景:
// src/schemas/example.ts
const Timestamps = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
const UserResponse = Base.merge(Timestamps).extend({ id: z.number() })这些方法可以链式调用,但建议不超过两层嵌套。拆开成独立的中间变量通常更清楚。
6. z.infer 与手写 interface 的选择
前面所有示例都用 z.infer 从 schema 推导类型。什么时候需要手写独立的 interface?
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 类型需要同时做运行时校验和编译期检查 | z.infer | 改 schema 自动改类型,一处变处处变 |
| 类型来自 ORM 或第三方库,无法控制 schema | 手写 interface | 类型由外部定义,不需要运行时校验 |
| 只需要编译期类型约束 | 手写 interface | 更轻量,不引入运行时开销 |
| 校验规则和类型声明需要独立演进 | 分开写 | 接口格式变了但校验规则没变,或者反过来 |
一个实际的选择标准:如果这个类型同时承担「运行时校验」和「编译期类型检查」两个职责,用 z.infer;如果只需要编译期类型,手写 interface 更轻量。
// src/features/users/types.ts
// 来自 ORM 的类型,没有对应的 Zod schema——手写 interface
export interface UserRecord {
id: number
email: string
name: string
passwordHash: string
role: 'admin' | 'user'
createdAt: Date
updatedAt: Date
}
// DTO 类型从 Zod schema 推导——z.infer
import { CreateUser, UserResponse } from './schemas'
export type CreateUserDTO = z.infer<typeof CreateUser>
export type UserResponseDTO = z.infer<typeof UserResponse>注意 UserRecord 和 UserResponseDTO 的区别:前者是数据库驱动给出的完整记录,后者是接口层实际传输的字段。
7. 数据库模型到 DTO 的转换
有了 Input DTO 和 Output DTO 之后,中间需要一个转换步骤。mapper 函数负责这件事:
// src/features/users/mappers.ts
import type { UserRecord } from './types'
import type { CreateUserDTO, UserResponseDTO } from './dto'
// 输入转换:DTO → 数据库写入参数
export function toUserRecord(input: CreateUserDTO): Omit<UserRecord, 'id' | 'createdAt' | 'updatedAt'> {
return {
email: input.email,
name: input.name,
passwordHash: hashPassword(input.password),
role: 'user',
}
}
// 输出转换:数据库记录 → DTO
export function toUserOutput(record: UserRecord): UserResponseDTO {
return {
id: record.id,
email: record.email,
name: record.name,
role: record.role,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
}
}mapper 的价值在于把「字段映射」和「业务逻辑」分开。改数据库字段或接口字段时只改 mapper,service 和 route 不用动。批量转换时直接 .map(toUserOutput) 就行。
8. 避免过度暴露:过滤敏感字段
直接把数据库记录返回给客户端,常见的风险是泄露敏感字段。典型的有:passwordHash、internalStatus、deletedAt、apiKey 等。
输出 DTO 在这里兜底。用 .pick() 显式选取对外暴露的字段,比用 .omit() 排除敏感字段更安全——新增字段时,pick 默认不包含新字段,需要显式添加才会暴露;而 omit 默认包含新字段,忘了加进排除列表就会直接返回给客户端。
// src/features/users/schemas.ts
const UserFull = z.object({
id: z.number(), email: z.string(), name: z.string(),
passwordHash: z.string(), internalStatus: z.string(),
role: z.string(), createdAt: z.string().datetime(),
})
// 显式选取,新增字段默认不暴露
export const UserOutput = UserFull.pick({
id: true, email: true, name: true, role: true, createdAt: true,
})9. Schema 版本与 API 演进
当 API 需要向后兼容时,schema 分层可以用来处理版本差异。比如 v1 的用户响应只有 name,v2 拆分成 firstName 和 lastName:
// src/features/users/schemas/v1.ts
export const UserResponseV1 = z.object({
id: z.number(),
name: z.string(),
})
// src/features/users/schemas/v2.ts
export const UserResponseV2 = z.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
})mapper 层提供两套转换函数,路由层按版本分发:
// src/features/users/mappers.ts
export function toUserOutputV1(record: UserRecord) {
return { id: record.id, name: record.name }
}
export function toUserOutputV2(record: UserRecord) {
const [firstName, ...rest] = record.name.split(' ')
return { id: record.id, firstName, lastName: rest.join(' ') }
}// src/routes/v1/users.ts
app.get('/v1/users/:id', async (c) => {
const record = await userService.getById(c.req.param('id'))
return c.json(toUserOutputV1(record))
})数据库模型始终只有一份,变化的只是 DTO 和 mapper。存储结构稳定,接口格式可以独立演进。
10. 文件组织
schema 和 DTO 类型放在哪里,取决于项目规模。
小型项目——schema 和路由放在同一个文件:
src/routes/users.ts // 路由 + schema + 类型
中型项目——每个 feature 有自己的 schema、mapper、service:
src/
features/
users/
schemas.ts // Zod schemas(base、create、update、response)
mappers.ts // 数据库 ↔ DTO 转换函数
service.ts // 业务逻辑
types.ts // 手写 interface(ORM 类型等)
routes/
users.ts // 路由定义,引用 feature 里的 schema
大型项目——跨 feature 复用的 schema 抽到公共目录:
// src/schemas/common.ts
import { z } from 'zod'
export const PaginationQuery = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(20),
})
export const Timestamps = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})feature schema 组合通用 schema:
// src/features/users/schemas.ts
import { UserBase } from './base'
import { Timestamps } from '../../schemas/common'
export const UserResponse = z.object({ id: z.number() })
.merge(UserBase)
.merge(Timestamps)组织原则:schema 的变更影响范围应该尽可能小。 改用户注册字段不应该让文章模块重新编译。按 feature 隔离,配合公共 schema 复用,可以在灵活性和可维护性之间取得平衡。
延伸阅读
- 01-为什么需要请求验证 —
zValidator的基础用法和校验位置 - 08-Zod进阶变换 —
.transform()、.refine()等高级校验技巧 - Zod 官方文档 - Utilities —
.pick()、.omit()、.extend()、.partial()完整 API - Zod 官方文档 - Inference —
z.infer和类型推导机制 - Martin Fowler - DTO — DTO 模式的经典定义
总结
DTO 与 schema 分层要解决的核心问题,是让存储层的类型和接口层的类型各自独立演进。归纳起来有三个层次:
- Schema 分层:base → create → update → response,用 Zod 的组合方法(
.extend()、.pick()、.omit()、.partial())从 base 派生,避免重复定义 - 输入输出分离:Input DTO 描述客户端需要传什么,Output DTO 描述客户端能拿到什么,两者字段不同、职责不同
- 转换隔离:mapper 函数负责数据库模型和 DTO 之间的互转,service 层和 route 层不直接处理字段映射
记住这个分工,后面遇到字段变更、敏感信息泄露、API 版本兼容这些问题时,可以按层定位该改哪里。
下一篇讲类型推导——Zod 的 schema 怎样把类型信息一路传递到路由处理函数,以及在复杂场景下类型推导的边界和限制。