Types层设计

要点

  • 前面把路由拆到了 routes/,把共享类型集中到了 types.ts。随着业务继续膨胀,一个文件装不下所有类型
  • Types 层的职责是给整个项目提供「类型真相」——Bindings、Variables、业务实体、请求响应、工具类型,都从这里出
  • AppEnv 是整个 Hono 应用的类型根,它定义了 Bindings(环境变量和服务绑定)和 Variables(请求级变量)
  • 业务类型(User、Post 等)和数据库 schema 之间容易重复定义,需要一套机制让 schema 作为类型的唯一来源
  • 请求体和响应体的类型应该独立定义,不要直接复用数据库行类型
  • TypeScript 的工具类型(PartialPickOmitRecord)能省掉大量重复的类型声明
  • 类型导出按「单一入口」组织,其他文件只需要从一个地方 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 也开始膨胀:

  • 用户列表接口只需要 idname,创建用户接口需要 emailpassword,更新用户接口只需要 name
  • Post 类型里要不要带 author 字段?列表接口不需要,详情接口需要
  • 注册请求体和登录请求体都有 emailpassword,但注册还多一个 confirmPassword

如果全部写在一个 types.ts 里,这个文件很快会到三四百行。想找某个接口的请求类型,要翻很久。

这一篇要解决的就是:当类型多到一个文件装不下时,怎么组织 Types 层

2. Types 层的职责

先明确 Types 层到底管什么。它给整个项目提供「类型真相」,具体来说分这几类:

  1. 环境类型AppEnv,包含 BindingsVariables,告诉 Hono 你的运行时环境长什么样
  2. 业务实体类型UserPostComment 这些核心数据模型
  3. 请求类型:每个接口接收的请求体结构
  4. 响应类型:每个接口返回的数据结构
  5. 工具类型:分页参数、排序选项、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
  }
}

BindingsVariables 的区别值得说清楚:

  • 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&lt;T, K&gt;从 T 中去掉 K 字段去掉不需要暴露的字段
Pick&lt;T, K&gt;从 T 中只取 K 字段只要某几个字段
Partial&lt;T&gt;把 T 的所有字段变成可选更新操作,字段不一定全传

5. 请求类型和响应类型:独立定义

也许你会问:上面已经有了 CreateUserInput,直接用它作为请求体类型不就行了?

对于简单接口确实可以。但当业务复杂起来,请求体和响应体通常需要独立定义,原因有两个:

  1. 请求和响应不对称:创建用户时前端传 password,但响应里不返回 password
  2. 前端传的结构和数据库不完全一致:比如日期格式、枚举值的表达方式

独立的请求和响应类型让接口契约更清晰:

// 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&lt;string, string[]&gt; 等价于 &#123; [key: string]: string[] &#125;,表示键值对对象,键是字符串,值是字符串数组。典型用途是表单校验错误。

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 里手写一份吗?

取决于类型的用途:

  1. 请求体类型:如果已经用 Zod 做了校验,直接用 z.infer 推导就行,不用手写。手写两份反而多了一个需要同步的地方
  2. 响应类型:Zod 管不到响应,响应类型还是要手写
  3. 数据库推导类型UserNewUser):这些从 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数据库行的读写类型UserNewUser
Zod z.infer请求体类型(已做校验的接口)CreateUserRequest
手写响应类型、业务视图类型UserResponseUserProfile

避免重复定义的核心原则就一条:每种类型只在一个地方定义,其他地方只引用

常见的反模式是同一个数据结构在多个地方各写一份内联类型:

// 反模式:多个路由里各自内联同一个结构
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,响应类型需要手写
  • OmitPickPartial 裁剪和组合类型,避免从头重写
  • index.ts 做统一导出入口,其他文件只 import 一个路径
  • 每种类型只在一个地方定义,避免多处内联导致的重复和不一致

下一篇我们看中间件层的设计,讨论如何把认证、日志、错误处理这些横切关注点组织成可复用的中间件模块。