输入输出类型共享
要点
- 前后端各自定义同一份类型,是大部分全栈项目里最先出现的重复来源之一
- Zod schema 可以同时充当运行时校验规则和编译时类型来源,做到「一份定义,两处使用」
- Hono 的 RPC 模式让
hc()客户端从 app 类型自动推导请求参数和响应结构,不需要手写接口类型 - monorepo 里可以通过共享包集中管理 schema 和派生类型,让服务端与前端都从同一处导入
- 类型共享的成本随项目规模变化,小项目直接内联定义就够了,不需要引入共享包或代码生成
- 破坏性变更在共享类型里需要按「只加不删 + 版本号」的方式管理,避免一端升级另一端编译失败
1. 问题:同一份类型写两遍
一个常见的场景。后端定义了一个创建用户的接口,请求体需要 name、email 两个字段:
// apps/server/routes/users.ts
interface CreateUserInput {
name: string
email: string
}
app.post('/users', zValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
// ...
})前端在调这个接口时,往往也会写一份近似的类型:
// apps/web/src/api/users.ts
interface CreateUserPayload {
name: string
email: string
}
async function createUser(input: CreateUserPayload) {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return res.json()
}这两份类型做的事情相同,却分别出现在两个文件、两个包里。当后端给 CreateUserInput 加了一个 age?: number 字段时,前端的 CreateUserPayload 不会自动跟着变。TypeScript 编译器也帮不上忙——它只能检查单个编译上下文内的类型一致性,跨包的定义变更不在它的感知范围内。
这类问题的特征是:不会在编译阶段暴露,要等到联调、code review 或者线上才被发现。修复成本不高,但出现频率和「改一边忘另一边」的模式绑定在一起,随着接口数量增长会持续消耗注意力。
2. 思路:让一份定义同时服务两端
解决方向比较直接:如果两端都从同一份定义里取类型,改一处就能在全量编译中暴露所有不一致。
前面几篇介绍的 Zod 已经具备了这种能力。一份 schema 既是运行时的校验规则,也是编译时的类型来源:
// packages/shared/schemas/user.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
})
// 从 schema 派生类型,不需要手写 interface
export type CreateUserInput = z.infer<typeof createUserSchema>
// => { name: string; email: string; age?: number }服务端用它做校验:
// apps/server/routes/users.ts
import { createUserSchema } from '@myapp/shared'
app.post('/users', zValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
// data 的类型由 schema 自动推导
return c.json({ id: 1, ...data }, 201)
})前端用它做参数约束:
// apps/web/src/api/users.ts
import type { CreateUserInput } from '@myapp/shared'
async function createUser(input: CreateUserInput) {
// ...
}schema 改了就编译报错,不需要等到联调。这种模式的核心约束是:两端必须共享同一个 TypeScript 编译上下文,或者至少能引用到同一份类型定义。monorepo 天然满足这个条件。
3. monorepo 里的共享类型包
pnpm workspace + Turborepo 的结构下,新建一个共享包的成本很低:
packages/
shared/
src/
schemas/
user.ts
post.ts
index.ts
types/
index.ts
package.json
tsconfig.json
apps/
server/
web/
共享包的源码里直接写 schema 和 z.infer 派生的类型:
// packages/shared/src/schemas/user.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export const userResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
createdAt: z.string(),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export type UserResponse = z.infer<typeof userResponseSchema>// packages/shared/src/index.ts
export * from './schemas/user'
export * from './schemas/post'共享包的 package.json 需要注意一点:前端通常不需要 Zod 的运行时,只需要类型。可以在构建时把 .d.ts 输出为纯 TypeScript 类型声明,这样消费方不需要安装 Zod:
// packages/shared/package.json
{
"name": "@myapp/shared",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}tsconfig.json 里开启 declaration: true 和 declarationMap: true,构建工具会把 z.infer<typeof schema> 解析成普通的 TypeScript 类型写入 .d.ts。消费方拿到的只是类型信息,运行时不依赖 Zod。
服务端和前端分别在 package.json 里声明对 @myapp/shared 的依赖:
// apps/server/package.json
{
"dependencies": {
"@myapp/shared": "workspace:*"
}
}Turborepo 会在构建时自动处理依赖顺序,确保 @myapp/shared 先编译。
4. Hono RPC:从 app 类型到客户端推导
monorepo 共享包解决了一部分问题,但还有一个缺口:响应体的类型。服务端改了返回结构,前端的 UserResponse 类型不一定能同步——它取决于开发者是否记得去改共享包。
Hono 的 RPC 模式提供了一个更紧的绑定方式。hc() 客户端可以从 app 的路由类型里自动推导出每个接口的请求参数和响应结构:
// apps/server/app.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { createUserSchema, userResponseSchema } from '@myapp/shared'
const app = new Hono()
app.post('/users', zValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
// 业务逻辑...
const user = { id: 1, ...data, createdAt: new Date().toISOString() }
return c.json(user, 201)
})
export type AppType = typeof app// apps/web/src/api/client.ts
import { hc } from 'hono/client'
import type { AppType } from '@myapp/server/app'
// hc 从 AppType 推导每个接口的参数和响应类型
const client = hc<AppType>('http://localhost:8787')
export const api = client.api// apps/web/src/components/CreateUserForm.tsx
import { api } from '../api/client'
async function handleSubmit() {
// body 的类型由服务端的 createUserSchema 自动推导
// res.data 的类型由 c.json() 的返回值自动推导
const res = await api.users.$post({
json: {
name: 'Alice',
email: '[email protected]',
},
})
if (res.ok) {
const data = await res.json()
// data.id、data.createdAt 都有类型提示
console.log(data.id, data.createdAt)
}
}这条类型链路的传导路径是:
createUserSchema
→ zValidator('json', createUserSchema) 在服务端校验
→ app 路由类型记录输入和输出
→ hc<AppType>() 在客户端推导
→ $post({ json: ... }) 和 res.json() 都有类型
服务端的 schema 变了,客户端的 $post 调用处和 res.json() 解构处都会报类型错误。不需要改两处代码,也不需要记得同步任何共享类型——类型信息是从 app 定义里直接推导出来的。
RPC 模式的细节和更多用法在 07 章有专门展开,这里只关注它和类型共享的关系:RPC 让「共享类型」这件事变成了「共享 app 类型」,把接口契约的维护成本降到了一个点上。
5. 契约优先:先定义接口,再实现
RPC 模式和共享包模式有一个共同前提:接口定义要先于两端的具体实现。这种工作方式可以归纳为「契约优先」(contract-first)。
具体做法是把接口契约集中在一个地方维护:
// packages/shared/src/contracts/user.ts
import { z } from 'zod'
// 请求契约
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
// 响应契约
export const userResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
createdAt: z.string(),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export type UserResponse = z.infer<typeof userResponseSchema>服务端按契约实现:
// apps/server/routes/users.ts
import type { CreateUserInput, UserResponse } from '@myapp/shared'
app.post('/users', zValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
const user: UserResponse = {
id: 1,
name: data.name,
email: data.email,
createdAt: new Date().toISOString(),
}
return c.json(user, 201)
})前端按契约调用:
// apps/web/src/api/users.ts
import type { CreateUserInput, UserResponse } from '@myapp/shared'
async function createUser(input: CreateUserInput): Promise<UserResponse> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return res.json() as Promise<UserResponse>
}契约优先的好处在于,接口变更需要修改的只有一个地方——共享包里的 schema。改完 schema 之后跑一次 pnpm typecheck,所有不一致的地方都会报出来。
这种工作方式需要团队建立一个习惯:接口变更先改 schema,再改实现。 如果允许后端在实现里直接加字段而不更新 schema,契约就会退化成一个没人维护的文件。
6. Zod 作为唯一事实来源
前面提到的几种做法里,Zod schema 都扮演了同一个角色:同时承担运行时校验和编译时类型两个职责。这种「一份定义,两种用途」的特性,让它比较适合充当接口契约的唯一事实来源。
好处体现在两个方面:
- 校验规则和类型定义来自同一处。 不会出现「类型写了
age: number但校验没检查 age」这种不一致。 - 变更的影响面可以被编译器追踪。 改 schema 之后,TypeScript 会标出所有使用了
z.infer派生类型的地方,服务端和前端都在范围内。
如果前端需要的类型和 schema 推导出来的不完全一样——比如需要额外的 UI 展示字段——可以用 Zod 的 extend 或类型层面的 & 交叉:
// apps/web/src/types/user.ts
import { userResponseSchema } from '@myapp/shared'
import { z } from 'zod'
// 在共享 schema 基础上增加前端专属字段
export const userWithAvatarSchema = userResponseSchema.extend({
avatarUrl: z.string().url(),
})
export type UserWithAvatar = z.infer<typeof userWithAvatarSchema>这样既保持了单一事实来源,又允许各端在需要时扩展。
7. 类型共享的几种策略
根据项目的组织方式和部署模式,类型共享有几种常见的实现路径,从轻到重大致排列如下:
7.1 共享包直接引用
前面几节描述的方式。monorepo 内部建一个 packages/shared,服务端和前端都引用它。适合两端在同一个仓库、同一套技术栈的场景。
packages/shared/ → schema + 派生类型
apps/server/ → import { createUserSchema } from '@myapp/shared'
apps/web/ → import type { CreateUserInput } from '@myapp/shared'
7.2 RPC 类型导出
服务端把 AppType 导出为一个包或一个模块路径,前端通过 hc<AppType>() 消费。不需要维护额外的请求/响应类型——类型从 app 定义里自动推导。
apps/server/app.ts → export type AppType = typeof app
apps/web/client.ts → hc<AppType>('...')
7.3 类型生成
从 schema 或 OpenAPI 规范自动生成 TypeScript 类型。适合前后端独立部署、跨语言、或接口数量很大的场景。常见的工具有 openapi-typescript、Orval 等。
openapi.yaml → openapi-typescript → generated/types.d.ts
7.4 选择依据
判断标准主要看两件事:两端是否共享仓库,以及是否需要跨语言支持。
- 同一仓库、同一团队:共享包直接引用或 RPC 类型导出就够了
- 同一仓库、不同团队:RPC 类型导出减少沟通成本,服务端改了类型客户端编译就知道
- 跨仓库或跨语言:需要类型生成或 OpenAPI 规范,两端各自维护消费端代码
8. 什么时候不需要类型共享
不是所有项目都需要端到端的类型共享。过早引入共享包或 RPC 模式会带来额外的构建配置、依赖管理和发布流程。
可以按下面的线索判断:
- 只有一端消费 API(比如只有移动端),后端直接定义类型就够了
- 前后端在同一个 monorepo 里,接口不超过十几个,共享包里放 schema 和派生类型即可
- 前端有多个消费端(Web、App、第三方),或者前后端独立仓库部署,RPC 或 OpenAPI 生成才有足够的收益
- 对外提供的 API,消费方不受你控制,OpenAPI 规范是更合适的契约形式
一个务实的节奏是:先用 inline 类型把功能跑通,第二个客户端出现时再抽共享包,第三个客户端或跨仓库时再考虑 RPC 或代码生成。不要提前优化。
9. 处理破坏性变更
共享类型最怕的是破坏性变更:一端升了共享包版本,另一端还在用旧类型,编译直接挂掉。
几条可以落地的规则:
- 优先扩展,不删字段。 需要新增数据时,加可选字段,不改已有字段的名字或类型。旧的可选字段在消费方不再使用之后再标记废弃。
- 用版本号区分不同的契约。 如果必须做不兼容变更,把新版本放到
/v2/路径下,旧版本保持可用,给消费方留迁移时间。 - 渐进式迁移。 如果必须删字段,先让服务端同时写入新旧两个字段,确认所有消费方都迁到新版之后,再移除旧字段。
- CI 守门。 在 CI 里跑全量 typecheck,覆盖所有消费共享包的子项目。类型不兼容的 PR 直接阻断合并。
共享包本身的版本管理有两种做法。一种是在 monorepo 里用 workspace 协议 workspace:*,每次改动通过 Changeset 或手动 bump 版本号。另一种是把共享包独立发布到私有 npm registry,用 semver 管理。前者适合迭代期,后者适合稳定期。
对于 Turborepo 项目,可以在 turbo.json 里配置 typecheck 任务依赖,确保共享包变更时所有消费方都重新检查:
// turbo.json
{
"pipeline": {
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}这样 pnpm turbo typecheck 会先编译共享包,再检查服务端和前端,任何类型不一致都会暴露出来。
10. 完整示例:Hono + React 全栈类型共享
把前面的内容串起来,看一个完整的类型共享链路。
项目结构:
packages/
shared/
src/
schemas/
todo.ts
index.ts
apps/
server/
src/
app.ts
routes/
todo.ts
web/
src/
api/
client.ts
components/
TodoList.tsx
第一步,定义共享 schema:
// packages/shared/src/schemas/todo.ts
import { z } from 'zod'
export const createTodoSchema = z.object({
title: z.string().min(1),
completed: z.boolean().default(false),
})
export const todoResponseSchema = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
})
export type CreateTodoInput = z.infer<typeof createTodoSchema>
export type TodoResponse = z.infer<typeof todoResponseSchema>第二步,服务端实现:
// apps/server/src/app.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { createTodoSchema, todoResponseSchema } from '@myapp/shared'
import type { TodoResponse } from '@myapp/shared'
const app = new Hono()
const todos: TodoResponse[] = []
app.get('/todos', (c) => {
return c.json(todos)
})
app.post('/todos', zValidator('json', createTodoSchema), (c) => {
const data = c.req.valid('json')
const todo: TodoResponse = {
id: todos.length + 1,
title: data.title,
completed: data.completed,
}
todos.push(todo)
return c.json(todo, 201)
})
export type AppType = typeof app第三步,前端消费:
// apps/web/src/api/client.ts
import { hc } from 'hono/client'
import type { AppType } from '@myapp/server/app'
export const client = hc<AppType>('http://localhost:8787')// apps/web/src/components/TodoList.tsx
import { useEffect, useState } from 'react'
import { client } from '../api/client'
export function TodoList() {
const [todos, setTodos] = useState<Awaited<ReturnType<typeof fetchTodos>>>([])
async function fetchTodos() {
const res = await client.todos.$get()
return res.json()
}
async function addTodo(title: string) {
// title 和 completed 的类型由 createTodoSchema 推导
const res = await client.todos.$post({
json: { title, completed: false },
})
if (res.ok) {
setTodos(await fetchTodos())
}
}
useEffect(() => {
fetchTodos().then(setTodos)
}, [])
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title} - {todo.completed ? '完成' : '进行中'}
</li>
))}
</ul>
)
}这个例子里,从 schema 定义到服务端校验到前端调用,类型信息只定义了一次。新增一个接口只需要改三处:共享 schema、服务端路由、前端组件。类型会沿着 schema → app → hc() 的链路自动流通。
如果要做一个破坏性变更——比如把 title 改名为 content——修改共享 schema 之后,服务端的路由实现和前端的 $post 调用处都会报类型错误,不会遗漏。
11. 与其他方案的比较
11.1 tRPC
tRPC 也提供端到端类型安全,但它要求服务端和客户端通过专用的 tRPC 协议通信,不能直接用标准 REST 或 fetch。Hono RPC 的好处是建立在标准 HTTP 之上,hc() 客户端发出的就是普通的 fetch 请求,服务端也可以用任何 HTTP 客户端调用,不依赖 tRPC 的运行时。
如果项目已经用了 tRPC,迁移成本比较高,没必要为了类型共享切换到 Hono RPC。如果是新项目,Hono RPC 的约束更少。
11.2 OpenAPI 代码生成
OpenAPI 规范是一种和语言无关的接口描述格式。从 OpenAPI 生成 TypeScript 类型,可以支持跨语言的客户端消费。缺点是多了一层间接——先写 schema,再生成 OpenAPI,最后生成 TypeScript 类型,每一步都可能引入不一致。
对于 TypeScript 全栈项目,Hono RPC 的类型传导路径更短,中间没有生成步骤。对于需要给 Python、Go 等语言的客户端提供类型的场景,OpenAPI 是更合适的选择。
11.3 手写接口类型
最轻量的方式。前后端各写各的 interface,约定一致就行。适合接口数量很少、团队很小、变更不频繁的场景。缺点是约定全靠人记,时间一长就会漂移。
延伸阅读
- 01-为什么需要请求验证 — Zod schema 作为校验和类型双重来源的基础用法
- 07-Zod与Hono集成 — zValidator 的类型推导机制
- 07-Hono RPC与前后端类型共享 — RPC 模式的完整展开,包括
hc()的高级用法和错误处理 - Hono RPC 文档 —
hc()客户端和AppType推导的官方说明 - Zod 官方文档 —
z.infer、extend、transform等类型派生用法
总结
类型共享的核心问题只有一个:怎样让「接口契约」只维护一份。顺着项目规模从小到大,对应的做法可以归纳为三层:
- 内联定义:接口数量少、消费端单一时,各端各写各的类型,约定一致就行
- 共享包 + Zod schema:monorepo 内的多端消费场景,schema 同时承担运行时校验和编译时类型两个角色,改一处编译器就能标出所有不一致
- RPC 类型推导 / OpenAPI 生成:跨仓库、跨语言、或者接口数量多到人工维护不可行时,让类型从 app 定义或规范文件里自动生成
Zod schema 在前两种策略里扮演了双重角色:它既是运行时的校验规则,也是编译时的类型来源。这种「一份定义、两种用途」的特性,让它成为类型共享链路中比较合适的单一事实来源。
破坏性变更的管理也需要配套。只加字段不删字段、版本号隔离、CI 全量 typecheck,这些工程纪律决定了共享类型能不能长期维持。
下一篇讨论 AI 接口的参数校验设计——当接口的输入输出比传统 CRUD 更不确定时,前面介绍的校验和类型共享机制需要怎样调整。