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 做输入校验,返回用户列表时也用同一个类型。早期可以跑通,但用一段时间之后,几组矛盾会逐渐显现:

  1. 注册接口不需要 idcreatedAtupdatedAt——这些由服务端生成,客户端传了也没用,但 schema 要求它们必须存在
  2. 返回用户信息时,passwordHash 被一起返回——接口和数据库共用同一个类型,字段增减会同时影响两端
  3. 更新接口只需要部分字段——用户改昵称不需要传 emailrole,但 User schema 把它们全标成 required
  4. 不同接口对同一实体的字段需求不同——列表页只要 idname,详情页多几个字段,但它们绑定在同一个类型上

这些矛盾有一个共同的根源:存储层的类型和传输层的类型混在了一起。数据库模型描述的是「数据在磁盘上长什么样」,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,输出没有——密码不需要回传给客户端
  • 输出有 idrolecreatedAt,输入没有——这些由服务端生成
  • 输出里的 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>

注意 UserRecordUserResponseDTO 的区别:前者是数据库驱动给出的完整记录,后者是接口层实际传输的字段。

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. 避免过度暴露:过滤敏感字段

直接把数据库记录返回给客户端,常见的风险是泄露敏感字段。典型的有:passwordHashinternalStatusdeletedAtapiKey 等。

输出 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 拆分成 firstNamelastName

// 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 复用,可以在灵活性和可维护性之间取得平衡。

延伸阅读

总结

DTO 与 schema 分层要解决的核心问题,是让存储层的类型和接口层的类型各自独立演进。归纳起来有三个层次:

  1. Schema 分层:base → create → update → response,用 Zod 的组合方法(.extend().pick().omit().partial())从 base 派生,避免重复定义
  2. 输入输出分离:Input DTO 描述客户端需要传什么,Output DTO 描述客户端能拿到什么,两者字段不同、职责不同
  3. 转换隔离:mapper 函数负责数据库模型和 DTO 之间的互转,service 层和 route 层不直接处理字段映射

记住这个分工,后面遇到字段变更、敏感信息泄露、API 版本兼容这些问题时,可以按层定位该改哪里。

下一篇讲类型推导——Zod 的 schema 怎样把类型信息一路传递到路由处理函数,以及在复杂场景下类型推导的边界和限制。