Types层设计
要点
- 前面把路由拆到了
routes/,把共享类型集中到了types.ts。随着业务继续膨胀,一个文件装不下所有类型 - Types 层的职责是给整个项目提供「类型真相」——Bindings、Variables、业务实体、请求响应、工具类型,都从这里出
AppEnv是整个 Hono 应用的类型根,它定义了Bindings(环境变量和服务绑定)和Variables(请求级变量)- 业务类型(User、Post 等)和数据库 schema 之间容易重复定义,需要一套机制让 schema 作为类型的唯一来源
- 请求体和响应体的类型应该独立定义,不要直接复用数据库行类型
- TypeScript 的工具类型(
Partial、Pick、Omit、Record)能省掉大量重复的类型声明 - 类型导出按「单一入口」组织,其他文件只需要从一个地方 import
1. 从一个类型散落的问题说起
前面第 02 篇讲项目结构时,我们把类型定义集中到了 src/types.ts:
// src/types.ts
export type AppEnv = {
Bindings: {
JWT_SECRET: string
DB: D1Database
KV: KVNamespace
}
Variables: {
user: {
id: number
email: string
role: string
}
db: DrizzleD1Database
}
}这种做法在中等规模项目里够用。但当业务继续扩展——用户有个人资料、文章有标签和评论、权限分了好几种角色——你会发现 types.ts 也开始膨胀:
- 用户列表接口只需要
id和name,创建用户接口需要email和password,更新用户接口只需要name - Post 类型里要不要带
author字段?列表接口不需要,详情接口需要 - 注册请求体和登录请求体都有
email和password,但注册还多一个confirmPassword
如果全部写在一个 types.ts 里,这个文件很快会到三四百行。想找某个接口的请求类型,要翻很久。
这一篇要解决的就是:当类型多到一个文件装不下时,怎么组织 Types 层。
2. Types 层的职责
先明确 Types 层到底管什么。它给整个项目提供「类型真相」,具体来说分这几类:
- 环境类型:
AppEnv,包含Bindings和Variables,告诉 Hono 你的运行时环境长什么样 - 业务实体类型:
User、Post、Comment这些核心数据模型 - 请求类型:每个接口接收的请求体结构
- 响应类型:每个接口返回的数据结构
- 工具类型:分页参数、排序选项、API 通用响应包装等跨模块复用的类型
这些类型的共同特征是:它们不包含任何运行逻辑。Types 层只定义数据结构,不写业务代码。
一个比较清晰的目录组织方式是把 types.ts 升级成一个目录:
src/
types/
index.ts // 统一导出入口
env.ts // AppEnv 类型
user.ts // 用户相关类型
post.ts // 文章相关类型
common.ts // 通用工具类型(分页、排序、API 响应等)3. AppEnv:Hono 的类型根
AppEnv 是整个 Hono 应用最重要的类型。它决定了 c.env 能访问哪些环境变量和绑定,c.set() / c.get() 能存取哪些请求级变量。
// src/types/env.ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
export type AppEnv = {
Bindings: {
// 环境变量(来自 wrangler.jsonc 的 vars 和 wrangler secret)
JWT_SECRET: string
API_KEY: string
APP_NAME: string
// Cloudflare 服务绑定
DB: D1Database
KV: KVNamespace
BUCKET: R2Bucket
}
Variables: {
// 中间件通过 c.set() 设置的请求级变量
user: {
id: number
email: string
role: string
}
db: DrizzleD1Database
}
}Bindings 和 Variables 的区别值得说清楚:
Bindings里的东西来自 Cloudflare 平台,每次部署都固定存在,代码里通过c.env.JWT_SECRET访问Variables里的东西是中间件在请求过程中动态设置的,代码里通过c.get('user')访问。没有中间件提前设置的话,这个值是undefined
把 AppEnv 单独放一个文件,是因为它属于「基础设施层」的类型,和业务类型不在一个维度。
4. 业务类型:从 Schema 推导,不要手抄
项目中容易出现这样的问题:数据库 schema 定义了一份 User 表结构,TypeScript 又手写了一份 User 类型。两张表加了一个字段,忘了同步 TypeScript 那边,接口就报类型错误。
让数据库 schema 成为类型的唯一来源,TypeScript 类型从 schema 推导:
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const usersTable = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
password: text('password').notNull(),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
avatar: text('avatar'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})用 Drizzle 提供的工具类型,从这张表推导出 TypeScript 类型:
// src/types/user.ts
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
import type { usersTable } from '../db/schema'
// 从数据库查出来的行类型——包含所有字段
export type User = InferSelectModel<typeof usersTable>
// 插入时需要的类型——根据 schema 的 notNull / default 自动推算
export type NewUser = InferInsertModel<typeof usersTable>Schema 加了字段,User 类型自动更新。不存在两边不一致的可能。
不要把密码带进响应类型
数据库行类型包含 password 这种敏感字段,直接返回给前端肯定不行。这时候需要用工具类型裁剪一下:
// src/types/user.ts
// 对外暴露的用户信息,去掉 password
export type UserProfile = Omit<User, 'password'>
// 创建用户时前端传过来的字段(前端不需要传 id、createdAt、updatedAt)
export type CreateUserInput = Omit<NewUser, 'id' | 'createdAt' | 'updatedAt'>
// 更新用户时前端传过来的字段(所有字段可选)
export type UpdateUserInput = Partial<Omit<NewUser, 'id' | 'createdAt' | 'updatedAt'>>这里用到了 TypeScript 三个常用的工具类型,先列出来,后面还会反复出现:
| 工具类型 | 作用 | 用途 |
|---|---|---|
Omit<T, K> | 从 T 中去掉 K 字段 | 去掉不需要暴露的字段 |
Pick<T, K> | 从 T 中只取 K 字段 | 只要某几个字段 |
Partial<T> | 把 T 的所有字段变成可选 | 更新操作,字段不一定全传 |
5. 请求类型和响应类型:独立定义
也许你会问:上面已经有了 CreateUserInput,直接用它作为请求体类型不就行了?
对于简单接口确实可以。但当业务复杂起来,请求体和响应体通常需要独立定义,原因有两个:
- 请求和响应不对称:创建用户时前端传
password,但响应里不返回password - 前端传的结构和数据库不完全一致:比如日期格式、枚举值的表达方式
独立的请求和响应类型让接口契约更清晰:
// src/types/user.ts
// --- 请求类型 ---
// POST /api/users 的请求体
export type CreateUserRequest = {
email: string
name: string
password: string
role?: 'admin' | 'user' // 可选,默认 'user'
}
// PATCH /api/users/:id 的请求体
export type UpdateUserRequest = {
name?: string
email?: string
avatar?: string
}
// --- 响应类型 ---
// 单个用户的响应
export type UserResponse = {
id: number
email: string
name: string
role: string
avatar: string | null
createdAt: string // ISO 字符串,前端直接用
updatedAt: string
}
// 用户列表的响应(带分页信息)
export type UserListResponse = {
data: UserResponse[]
total: number
page: number
pageSize: number
}响应里的日期用 string 而不是 Date,因为 JSON 序列化之后日期就是字符串。类型和实际传输的数据结构保持一致,前端拿到数据时不会有意外。
6. 通用工具类型:跨模块复用的部分
分页、排序、API 通用响应这些类型会在多个模块里重复出现,适合抽到通用文件里:
// src/types/common.ts
// 分页查询参数——所有列表接口都会用到
export type PaginationQuery = {
page?: number // 页码,默认 1
pageSize?: number // 每页条数,默认 20
}
// 排序参数
export type SortQuery = {
sortBy?: string
order?: 'asc' | 'desc'
}
// 统一的 API 成功响应包装
export type ApiSuccess<T> = {
code: 0
data: T
message: string
}
// 统一的 API 错误响应
export type ApiError = {
code: number
message: string
details?: Record<string, string[]>
}有了这些通用类型,路由文件里写响应时就不用每次手敲一遍结构:
// src/routes/users.ts
import type { ApiSuccess, UserListResponse } from '../types'
// c.json() 的返回类型会有完整提示
return c.json<ApiSuccess<UserListResponse>>({
code: 0,
data: {
data: users,
total: 100,
page: 1,
pageSize: 20,
},
message: 'ok',
})Record<string, string[]> 等价于 { [key: string]: string[] },表示键值对对象,键是字符串,值是字符串数组。典型用途是表单校验错误。
7. 类型导出:统一从一个入口出去
目录里有好几个类型文件,其他模块引入时难道要写一堆路径?
// 不好的做法:到处散落
import type { User } from '../types/user'
import type { Post } from '../types/post'
import type { PaginationQuery } from '../types/common'
import type { AppEnv } from '../types/env'用 index.ts 做一个统一导出入口:
// src/types/index.ts
// 环境类型
export type { AppEnv } from './env'
// 用户类型
export type { User, NewUser, UserProfile, CreateUserRequest, UpdateUserRequest, UserResponse } from './user'
// 文章类型
export type { Post, NewPost, CreatePostRequest, PostResponse } from './post'
// 通用类型
export type { PaginationQuery, SortQuery, ApiSuccess, ApiError } from './common'这样其他文件引入时只需要写一个路径:
// src/routes/users.ts
import type { AppEnv, CreateUserRequest, UserResponse, ApiSuccess } from '../types'用 export type 而不是 export,是因为这些只是类型,不产生运行时代码。TypeScript 编译时会把 export type 的语句全部擦除,不会打包到产物里。
8. 类型与 Zod Schema 的关系
如果项目里用了 Zod 做请求校验,你会遇到一个问题:Zod schema 本身就能推导类型,和手写的类型会不会重复?
比如用户创建的 Zod schema:
// src/schemas/user.ts
import { z } from 'zod'
export const createUserSchema = z.object({
email: z.string().email('邮箱格式不正确'),
name: z.string().min(1, '名称不能为空').max(50),
password: z.string().min(8, '密码至少 8 位'),
role: z.enum(['admin', 'user']).optional(),
})这个 schema 可以推导出类型:
// code.ts
type CreateUserRequest = z.infer<typeof createUserSchema>
// 等效于:
// { email: string; name: string; password: string; role?: 'admin' | 'user' }既然 Zod 能推导出类型,还需要在 types/user.ts 里手写一份吗?
取决于类型的用途:
- 请求体类型:如果已经用 Zod 做了校验,直接用
z.infer推导就行,不用手写。手写两份反而多了一个需要同步的地方 - 响应类型:Zod 管不到响应,响应类型还是要手写
- 数据库推导类型(
User、NewUser):这些从 Drizzle schema 推导,和 Zod 无关
一个比较清晰的分法:
// src/types/user.ts
import type { z } from 'zod'
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
import type { usersTable } from '../db/schema'
import type { createUserSchema, updateUserSchema } from '../schemas/user'
// 从数据库推导
export type User = InferSelectModel<typeof usersTable>
export type NewUser = InferInsertModel<typeof usersTable>
// 从 Zod schema 推导(请求类型)
export type CreateUserRequest = z.infer<typeof createUserSchema>
export type UpdateUserRequest = z.infer<typeof updateUserSchema>
// 手写的响应类型
export type UserProfile = Omit<User, 'password'>
export type UserResponse = {
id: number
email: string
name: string
role: string
avatar: string | null
createdAt: string
updatedAt: string
}每种类型有一个明确的来源,不会出现「这个类型到底是手写的还是推导的」的困惑。
9. 避免类型重复定义
整理一下前面几节,Types 层里有三类类型来源:
| 类型来源 | 适用场景 | 示例 |
|---|---|---|
Drizzle InferSelectModel / InferInsertModel | 数据库行的读写类型 | User、NewUser |
Zod z.infer | 请求体类型(已做校验的接口) | CreateUserRequest |
| 手写 | 响应类型、业务视图类型 | UserResponse、UserProfile |
避免重复定义的核心原则就一条:每种类型只在一个地方定义,其他地方只引用。
常见的反模式是同一个数据结构在多个地方各写一份内联类型:
// 反模式:多个路由里各自内联同一个结构
app.post('/', async (c) => {
const body: { email: string; name: string; password: string } = await c.req.json()
})
app.post('/register', async (c) => {
const body: { email: string; name: string; password: string } = await c.req.json()
})改了一边忘了另一边,类型就不一致了。正确的做法是定义一次、多处引用:
// src/types/user.ts
export type CreateUserRequest = {
email: string
name: string
password: string
}
// src/routes/users.ts 和 src/routes/auth.ts
import type { CreateUserRequest } from '../types'
app.post('/', async (c) => {
const body = await c.req.json<CreateUserRequest>()
})判断要不要抽成独立类型的标准很简单:这个类型会在两个以上的地方用到吗? 会的话就抽出去,不会的话就内联在路由里,没必要提前抽象。
总结
回顾一下这篇的要点:
- Types 层分五类:环境类型、业务实体类型、请求类型、响应类型、通用工具类型
AppEnv单独放env.ts,它是 Hono 应用的类型根- 业务实体类型从 Drizzle schema 推导(
InferSelectModel/InferInsertModel),不要手抄 - 请求类型能用 Zod 推导就用
z.infer,响应类型需要手写 - 用
Omit、Pick、Partial裁剪和组合类型,避免从头重写 - 用
index.ts做统一导出入口,其他文件只 import 一个路径 - 每种类型只在一个地方定义,避免多处内联导致的重复和不一致
下一篇我们看中间件层的设计,讨论如何把认证、日志、错误处理这些横切关注点组织成可复用的中间件模块。