API类型共享规范
要点
- 前面几篇讲完了 Hono 的 RPC 能力和
hc客户端,类型可以从后端一路传导到前端。能力有了,但如果没有共享规范,能力反而会成为混乱的来源 - 共享类型的核心问题是「谁拥有、放在哪里、怎样变更」——命名和组织只是表象
- 把服务端内部类型和前后端共享类型放在不同目录,是减少误伤的第一步
- 类型变更的沟通成本往往比代码修改更高,约定版本策略和迁移流程可以提前降低协作摩擦
- 共享类型需要测试,但测试目标不是类型本身,而是 schema 的约束边界
- Code Review 时需要一组具体的检查项来判断一次类型变更是否安全
1. 为什么需要共享规范
03 讲过,hc 客户端通过 import type { AppType } 拿到后端路由的完整类型信息。这条路走得通,不代表团队可以随意使用它。
一个常见问题是:前端为了图方便,直接从后端深处导入了大量内部类型。后端重构时改了这些类型的字段,编译报错的不是后端自己的文件,而是前端的十几个页面。改的人不知道自己改了什么影响面,被改的人不知道自己该去找谁确认。
规范要解决的就是这类问题,具体可以归纳成三层:
- 一致性——不同模块的共享类型遵循相同的命名和组织方式,新人看一个例子就能推断其他模块的写法
- 可维护性——类型变更时,影响面可控、迁移路径清晰,不会牵一发动全身
- 所有权——谁有权修改共享类型、变更需要经过什么流程,避免「谁用到谁改」的混乱局面
规范的价值在于把后期才暴露的问题提前到项目初期就解决掉。
2. 命名规范
共享类型的命名需要回答一个核心问题:看到这个类型名,能不能判断它属于哪一层、被谁使用。
2.1 类型分类与命名
按使用范围分成三层:
| 分类 | 说明 | 命名规则 | 示例 |
|---|---|---|---|
| 共享类型 | 前后端都会用到 | 无前缀 | User、Post |
| 请求类型 | 仅前端构造请求 | *Input | CreateUserInput |
| 响应类型 | 仅前端消费响应 | *Output | UserListOutput |
项目中通常同时存在 Zod schema 和 TypeScript 类型,两者需要明确区分:
// shared/schemas/user.schema.ts
// Schema 用 camelCase + Schema 后缀
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
// 从 Schema 推导出的类型用 PascalCase,不带 Schema 后缀
export type CreateUserInput = z.infer<typeof createUserSchema>// shared/types/user.ts
// 纯类型定义用 PascalCase,与 Schema 推导出的类型区分
export interface User {
id: number
name: string
email: string
createdAt: Date
}命名规则只有一条:从名字能看出来源。看到 CreateUserInput 知道它是从 schema 推导的请求体,看到 User 知道它是业务实体类型。
2.2 避免的名字
- 过于通用的名字——
Data、Response、Result。多个模块同时导出时会频繁冲突,加上业务前缀:UserResponse、PostListResult - 带版本号的类型名——
UserV2、UserNew。版本号应该体现在路径或模块上,不应该黏在类型名上 - 带实现细节的名字——
ZodUser、ValidatedPost。前端不需要知道你用了 Zod
3. 文件组织
3.1 目录结构
共享类型需要和服务端内部类型做物理隔离:
src/
├── shared/ # 前后端共享的类型
│ ├── schemas/ # Zod schema 定义
│ ├── types/ # 纯类型定义
│ └── index.ts # 统一导出入口
├── server/ # 仅服务端使用的代码
│ ├── types/ # 服务端内部类型(数据库模型等)
│ └── routes/ # 路由定义,导出 AppType
└── client/ # 仅前端使用的代码
├── api/ # hc 客户端封装
└── hooks/ # React Query hooks
3.2 隔离原则
三条核心规则:
shared/不能 importserver/或client/的内容。 这条可以用 lint 规则强制执行(第 7 节)。shared/是叶子节点,两侧都可以依赖它,但它不依赖任何一侧server/types/的类型不导出给前端。 数据库模型、ORM 类型属于这一层。前端需要某个字段时,把相关字段复制到shared/types/- 路由文件只导出
AppType,不导出业务类型。 前端通过AppType获取路由信息,通过shared/获取业务类型
shared/index.ts 作为统一导出入口,前端只需 import type { User } from '@/shared',不需要记住每个类型具体在哪个文件里。
4. 版本策略
4.1 什么时候需要版本
大多数团队在早期不需要类型版本管理。真正需要版本管理的场景是:后端先部署了,前端还没来得及更新。 如果类型变更导致前端的请求格式不兼容,线上就会出错。
判断标准:
- 向后兼容的变更(加可选字段、加新路由)——直接改
- 破坏性的变更(删字段、改类型、改路径)——需要版本管理或协调部署顺序
4.2 处理破坏性变更
协调部署顺序(小团队推荐)——后端先加新字段、不删旧字段,等前端迁移完再清理:
export interface User {
id: number
name: string // 暂时保留
username?: string // 新增,可选
email: string
}URL 路径版本化——旧版本保持可用,新版本独立部署。适合前后端团队独立迭代的情况:
// server/index.ts
const route = app
.route('/api/v1/users', v1Users)
.route('/api/v2/users', v2Users)app.route() 的返回值也需要链式调用才能正确推导类型,和 03 里讲的路由链式写法原理相同。
类型层面标注——用 @deprecated 让 IDE 显示删除线提示:
/**
* @deprecated 将在 2026-09-01 替换为 UserV2,届时 name 字段会移除
* @see UserV2
*/
export interface User { id: number; name: string; email: string }
export interface UserV2 { id: number; username: string; email: string }4.3 变更日志
每次共享类型变更,在 PR 描述或 commit message 中标注影响范围:
feat(shared): 用户类型新增 username 字段
- shared/types/user.ts: 新增 username 可选字段
- 影响面:前端 user 相关页面可选择性使用
- 破坏性:无
5. 测试规范
共享类型的测试目标不是验证类型定义本身——TypeScript 编译器已经做了这件事。测试要覆盖的是 schema 的约束边界:哪些值合法、哪些值会被拒绝。
5.1 Schema 单元测试
// tests/shared/user.schema.test.ts
import { describe, it, expect } from 'vitest'
import { createUserSchema } from '@/shared/schemas/user.schema'
describe('createUserSchema', () => {
it('接受合法输入', () => {
const result = createUserSchema.safeParse({
name: 'Alice',
email: '[email protected]',
})
expect(result.success).toBe(true)
})
it('拒绝空 name', () => {
const result = createUserSchema.safeParse({ name: '', email: '[email protected]' })
expect(result.success).toBe(false)
})
it('拒绝无效 email', () => {
const result = createUserSchema.safeParse({ name: 'Alice', email: 'not-email' })
expect(result.success).toBe(false)
})
})5.2 类型兼容性测试
用 expectTypeOf 做编译期类型测试,确保类型推导结果符合预期:
// tests/shared/user.types.test.ts
import { expectTypeOf, test } from 'vitest'
import type { CreateUserInput } from '@/shared'
test('CreateUserInput 应包含 name 和 email,不应包含 id', () => {
expectTypeOf<CreateUserInput>().toMatchTypeOf<{ name: string; email: string }>()
expectTypeOf<CreateUserInput>().not.toHaveProperty('id')
})5.3 哪些不测
- 纯
interface/type定义不需要测试——编译器会检查 - 前端调用
hc的方式不需要为共享类型单独写测试——那是集成测试的范畴 - 不要把 schema 测试变成「测 Zod 库本身」——只测你定义的约束逻辑
6. Code Review 检查清单
每次涉及共享类型变更的 PR,审查时按以下清单确认:
兼容性检查
- 是否有字段被删除或改名?前端是否已同步适配?
- 是否有字段类型发生变化?比如
string改成number - 是否有可选字段变成了必填?路由路径是否有变更?
组织规范检查
- 新增类型是否放在了正确的目录(
shared/vsserver/types/)? -
shared/下是否有 importserver/或client/的内容? - 命名是否符合约定的前缀和后缀规则?
沟通与测试检查
- PR 描述是否标注了影响范围和是否破坏性变更?
- 破坏性变更是否和受影响的前端开发者确认过迁移时间线?
- Schema 变更是否有对应的测试更新?
对于涉及 shared/ 目录变更的 PR,建议至少过一遍兼容性检查和沟通检查。
7. 常见反模式
7.1 过度共享
把服务端内部类型直接导出给前端:
// ❌ server/types/db.ts - 数据库模型包含服务端专属字段
export interface UserModel {
id: number; name: string; email: string
passwordHash: string // 不应该暴露给前端
internalFlags: string[] // 服务端内部使用
}
// ❌ client/hooks/use-users.ts - 前端直接 import 了数据库模型
import type { UserModel } from '@/server/types/db'修正:在 shared/types/ 里定义前端需要的字段子集。
7.2 共享不足
前端重新定义了一套和后端 schema 等价的类型:
// ❌ client/types/user.ts - 前端手抄了一份同样的类型
export interface CreateUserRequest { name: string; email: string }
// 后端 schema 已经定义了一样的结构,两边必须人工保持同步修正:前端直接用 z.infer 推导出的类型,或者从 shared/ import。
7.3 类型泄漏
服务端内部类型通过 AppType 间接传递到前端,导致前端依赖了后端实现细节。比如后端用了 nanoid 返回 string 类型的 id,前端通过 AppType 推导后也拿到 string,但代码里按 number 使用——编译通过,运行时出错。
修正:在 shared/types/ 里显式定义 API 响应的数据结构,后端在 handler 里用这个类型做序列化,不依赖隐式的 AppType 推导。
8. 工具支持
8.1 ESLint 边界规则
用 eslint-plugin-import 的 no-restricted-paths 强制目录隔离:
// .eslintrc.json
{
"rules": {
"no-restricted-paths": ["error", {
"zones": [
{ "target": "./src/shared", "from": ["./src/server", "./src/client"] },
{ "target": "./src/client", "from": ["./src/server"], "except": ["./src/shared"] }
]
}]
}
}这条规则在编码阶段就能拦住错误的 import 方向。
8.2 TypeScript 路径别名
配合 tsconfig.json 让 import 路径本身就说明类型的归属层级:
// tsconfig.json
{ "compilerOptions": { "paths": {
"@shared/*": ["./src/shared/*"],
"@server/*": ["./src/server/*"],
"@client/*": ["./src/client/*"]
}}}8.3 CI 类型隔离检查
# .github/workflows/type-check.yml
- name: Check shared isolation
run: |
if grep -r "from.*@server\|from.*@client" src/shared/; then
echo "ERROR: shared/ 不能依赖 server/ 或 client/"; exit 1
fi9. 团队工作流与实践规范
9.1 所有权
- 后端开发者拥有
shared/schemas/的修改权——schema 和服务端校验逻辑直接关联 - 前端开发者可以提 PR 修改
shared/types/,但需要后端 review - 任何人对
shared/的修改都需要在 PR 描述中说明影响范围
9.2 变更流程
- 后端在
shared/schemas/中修改 schema → 2.pnpm typecheck+pnpm test确认通过 → 3. PR 描述标注影响范围 → 4. 前端 review 确认适配方案 → 5. 合并后前端适配,协调部署
9.3 团队规范速查
把前面各节收拢成一份可执行的规范:
| 维度 | 规则 |
|---|---|
| 目录 | shared/schemas/(Zod schema)、shared/types/(纯类型)、server/types/(内部类型不共享) |
| 命名 | Schema: camelCase + Schema;推导类型: PascalCase + Input/Output;纯类型: PascalCase + 业务前缀 |
| 依赖 | shared/ 不依赖任何目录;server/ 和 client/ 不互相依赖 |
| 变更 | 加可选字段直接改;删字段标记 @deprecated 保留 2 周;改路径用 URL 版本化 |
| Review | shared/ 的 PR 至少一前端 + 一后端 review;PR 描述须含影响范围和是否破坏性变更 |
延伸阅读
- 03-Hono Client使用方式 —
hc客户端的基本用法和类型推导机制 - 06-单仓库Monorepo实践 — monorepo 下类型共享的工程化配置
- Zod 官方文档 - inferring —
z.infer类型推导的完整说明 - eslint-plugin-import no-restricted-paths — 路径限制规则的配置方式
总结
API 类型共享的规范可以从四个维度归纳:
- 分层——shared、server、client 三个目录各自承担明确的职责,依赖方向单向流动
- 命名——从类型名能看出它的来源(schema 推导还是手写)和使用范围(共享还是内部)
- 变更——向后兼容的变更直接改,破坏性变更需要版本策略或协调部署,每次变更标注影响范围
- 守护——lint 规则守住目录边界,schema 测试守住约束逻辑,code review 守住协作流程
规范本身不复杂,难的是在一开始就执行。等项目变大、前端开始抱怨后端随便改接口的时候再补规范,成本会高很多。
下一篇对比 Hono RPC 与 OpenAPI 两种类型共享方案,看看它们在代码生成、运行时校验、跨语言支持等维度上各有什么取舍。