API类型共享规范

要点

  • 前面几篇讲完了 Hono 的 RPC 能力和 hc 客户端,类型可以从后端一路传导到前端。能力有了,但如果没有共享规范,能力反而会成为混乱的来源
  • 共享类型的核心问题是「谁拥有、放在哪里、怎样变更」——命名和组织只是表象
  • 把服务端内部类型和前后端共享类型放在不同目录,是减少误伤的第一步
  • 类型变更的沟通成本往往比代码修改更高,约定版本策略和迁移流程可以提前降低协作摩擦
  • 共享类型需要测试,但测试目标不是类型本身,而是 schema 的约束边界
  • Code Review 时需要一组具体的检查项来判断一次类型变更是否安全

1. 为什么需要共享规范

03 讲过,hc 客户端通过 import type { AppType } 拿到后端路由的完整类型信息。这条路走得通,不代表团队可以随意使用它。

一个常见问题是:前端为了图方便,直接从后端深处导入了大量内部类型。后端重构时改了这些类型的字段,编译报错的不是后端自己的文件,而是前端的十几个页面。改的人不知道自己改了什么影响面,被改的人不知道自己该去找谁确认。

规范要解决的就是这类问题,具体可以归纳成三层:

  1. 一致性——不同模块的共享类型遵循相同的命名和组织方式,新人看一个例子就能推断其他模块的写法
  2. 可维护性——类型变更时,影响面可控、迁移路径清晰,不会牵一发动全身
  3. 所有权——谁有权修改共享类型、变更需要经过什么流程,避免「谁用到谁改」的混乱局面

规范的价值在于把后期才暴露的问题提前到项目初期就解决掉。

2. 命名规范

共享类型的命名需要回答一个核心问题:看到这个类型名,能不能判断它属于哪一层、被谁使用。

2.1 类型分类与命名

按使用范围分成三层:

分类说明命名规则示例
共享类型前后端都会用到无前缀UserPost
请求类型仅前端构造请求*InputCreateUserInput
响应类型仅前端消费响应*OutputUserListOutput

项目中通常同时存在 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 避免的名字

  • 过于通用的名字——DataResponseResult。多个模块同时导出时会频繁冲突,加上业务前缀:UserResponsePostListResult
  • 带版本号的类型名——UserV2UserNew。版本号应该体现在路径或模块上,不应该黏在类型名上
  • 带实现细节的名字——ZodUserValidatedPost。前端不需要知道你用了 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 隔离原则

三条核心规则:

  1. shared/ 不能 import server/client/ 的内容。 这条可以用 lint 规则强制执行(第 7 节)。shared/ 是叶子节点,两侧都可以依赖它,但它不依赖任何一侧
  2. server/types/ 的类型不导出给前端。 数据库模型、ORM 类型属于这一层。前端需要某个字段时,把相关字段复制到 shared/types/
  3. 路由文件只导出 AppType,不导出业务类型。 前端通过 AppType 获取路由信息,通过 shared/ 获取业务类型

shared/index.ts 作为统一导出入口,前端只需 import type &#123; User &#125; 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/ vs server/types/)?
  • shared/ 下是否有 import server/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-importno-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
    fi

9. 团队工作流与实践规范

9.1 所有权

  • 后端开发者拥有 shared/schemas/ 的修改权——schema 和服务端校验逻辑直接关联
  • 前端开发者可以提 PR 修改 shared/types/,但需要后端 review
  • 任何人对 shared/ 的修改都需要在 PR 描述中说明影响范围

9.2 变更流程

  1. 后端在 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 版本化
Reviewshared/ 的 PR 至少一前端 + 一后端 review;PR 描述须含影响范围和是否破坏性变更

延伸阅读

总结

API 类型共享的规范可以从四个维度归纳:

  1. 分层——shared、server、client 三个目录各自承担明确的职责,依赖方向单向流动
  2. 命名——从类型名能看出它的来源(schema 推导还是手写)和使用范围(共享还是内部)
  3. 变更——向后兼容的变更直接改,破坏性变更需要版本策略或协调部署,每次变更标注影响范围
  4. 守护——lint 规则守住目录边界,schema 测试守住约束逻辑,code review 守住协作流程

规范本身不复杂,难的是在一开始就执行。等项目变大、前端开始抱怨后端随便改接口的时候再补规范,成本会高很多。

下一篇对比 Hono RPC 与 OpenAPI 两种类型共享方案,看看它们在代码生成、运行时校验、跨语言支持等维度上各有什么取舍。