本节目标:读完这一节,你能判断一个项目该用「手工复制」「共享包」还是「代码生成」来同步前后端类型;能写出一个带判别字段的 API 响应契约并让调用方在
ok上收窄类型;能用 Zod 让运行时校验和静态类型来自同一个 schema,从而杜绝两者漂移;还能说清加字段、删字段、改类型分别算不算破坏性变更,并避开日期、bigint、undefined这三类序列化陷阱。
17.2 前后端共享类型与 API 契约
上一节我们把项目分了层,但留下了一个尾巴:UserResponseDto 在前端和后端各写了一份。这不是笔误,而是全栈开发里最常见的痛点——两份类型定义在各自的编译单元里都合法,却可以在字段名、字段类型、可空性上悄悄不一致。
这一节要把这个尾巴彻底剪掉。核心问题只有一个:同一个数据结构,怎么才能只定义一次,让前后端都被它约束?
类型系统的边界在哪里
先接受一个不太舒服的事实:TypeScript 的类型只在编译单元内有效,跨进程、跨网络时它什么也保证不了。
// 后端:返回给前端的对象
interface User {
id: number;
username: string;
}
// 前端:独立定义的类型
interface User {
id: string;
name: string;
}
这两份定义各自都能编译通过,因为它们是两个互不相干的世界。真正的断裂发生在运行时:前端拿到 id 是数字,却按字符串处理,user.id.startsWith("u_") 直接抛异常。这个错误在第 13 章我们讲过根源——类型擦除带来的运行时盲区
,编译后类型信息全部消失,网络对面送来的只是普通的 JSON。
所以「共享类型」要解决两件事:让定义只有一处(消除漂移),以及让运行时数据也被检查(弥补擦除)。
三种共享方案及其代价
| 方案 | 做法 | 优点 | 代价 | 适合 |
|---|---|---|---|---|
| 手工复制 | 前后端各写一份 interface | 零基础设施成本 | 必然漂移,靠人盯 | 一次性原型 |
| 共享包 | monorepo 里放 packages/contracts | 单一事实来源,改一处两边编译报错 | 需要 monorepo 与构建配置 | 自家前后端 |
| 代码生成 | 从 OpenAPI / GraphQL schema 生成 | 后端 schema 是唯一权威 | 生成物要纳入 CI,调试链路变长 | 多客户端、对外 API |
选择的关键不是「哪个高级」,而是改动由谁发起。如果契约的变更总是自家前端提需求,共享包最省事;如果 API 要对外开放、有第三方客户端,那么以 schema 为权威、生成多语言 SDK 才划算——本站的 GraphQL 代码生成与类型安全工作流 展示的就是后一种路子。
共享包怎么放进 monorepo,在 Monorepo 与 Project References 里已经讲过工具配置,这一节专注在契约本身该怎么写。
把契约写成显式的请求与响应类型
第一步是别再让接口返回「裸对象」。把所有响应包进一个带判别字段的联合类型——判别联合我们在 判别联合 里专门练过,这里是它最实用的落地场景:
// packages/contracts/src/api.ts
export interface ApiSuccess<T> {
ok: true;
data: T;
}
export interface ApiFailure {
ok: false;
code: string;
message: string;
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
这样写的好处立刻体现在调用方:只要判断 res.ok,TypeScript 就会自动把类型收窄,成功分支上一定有 data,失败分支上一定有 code,你不可能在没检查的情况下访问 data:
function render(res: ApiResponse<UserDto>): string {
if (res.ok) {
return res.data.email; // 这里 res 已收窄为 ApiSuccess<UserDto>
}
return `请求失败:${res.code}`; // 这里已收窄为 ApiFailure
}
第二步是给路由建一张「契约表」,把每条接口的请求和响应都写进一个类型里:
// packages/contracts/src/routes.ts
export interface UserDto {
id: string;
email: string;
createdAt: string; // 契约层统一用 ISO 字符串表示时间
}
export interface ApiRoutes {
"GET /users/:id": {
request: { id: string };
response: ApiResponse<UserDto>;
};
"POST /users": {
request: { email: string; password: string };
response: ApiResponse<UserDto>;
};
}
有了这张表,客户端封装函数就能用泛型把「路由字符串」和「请求/响应类型」绑死。这里用到了索引访问类型,也就是 keyof·typeof 与索引访问类型
里讲过的 T[K] 写法:
// packages/contracts/src/client.ts
export async function call<K extends keyof ApiRoutes>(
route: K,
body: ApiRoutes[K]["request"],
): Promise<ApiRoutes[K]["response"]> {
const res = await fetch(route, {
method: route.split(" ")[0],
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return (await res.json()) as ApiRoutes[K]["response"];
}
调用时体验很好:call("POST /users", { email, password }) 会检查请求字段,返回值也自动带上正确类型;路由名写错或请求体缺字段都会在编译期报错,典型报错是:
error TS2345: Argument of type '{ email: string; }' is not assignable to parameter of type '{ email: string; password: string; }'.
Property 'password' is missing in type '{ email: string; }' but required in type '{ email: string; password: string; }'.
一个必须承认的漏洞:as 只是断言
上面那段 call 里有一行 as ApiRoutes[K]["response"]。这个断言完全是骗人的——它没有检查任何东西,只是告诉编译器「相信我」。如果服务端因为 bug 返回了 { ok: true, data: null },编译器会一路放行,直到某个地方 .email 炸掉。
const res = await call("GET /users/:id", { id: "u_1" });
if (res.ok) {
console.log(res.data.email.toUpperCase());
// 若服务端实际返回 data: null —— TypeError: Cannot read properties of null
}
修复办法只有一条:在边界处做运行时校验。而手工写 if (typeof x.id === "string") 既啰嗦又会和类型定义漂移,所以要用 schema 库把两者合成一件事。
用 Zod 让校验与类型同源
核心思路是:schema 是唯一事实来源,类型从 schema 推导出来。这样类型和校验永远不可能不一致,因为类型本来就是 schema 算出来的。
// packages/contracts/src/user.ts
import { z } from "zod";
export const userDtoSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// 类型不是手写的,而是推导的
export type UserDto = z.infer<typeof userDtoSchema>;
z.infer 的推导细节我们在 Zod 模式验证与类型推导
里拆过,这里只关心它在全栈里的用法:同一个 userDtoSchema 既能在服务端校验请求体,也能在客户端校验响应体。客户端改写 call 让它不再骗人:
export async function callChecked<K extends keyof ApiRoutes>(
route: K,
body: ApiRoutes[K]["request"],
): Promise<ApiResponse<unknown>> {
const raw = await (await fetch(route, { body: JSON.stringify(body) })).json();
const parsed = userDtoSchema.safeParse(raw);
if (!parsed.success) {
// 校验失败也是一种失败响应,交给上层统一处理
return { ok: false, code: "INVALID_RESPONSE", message: parsed.error.message };
}
return { ok: true, data: parsed.data };
}
注意 safeParse 与 parse 的区别:parse 失败会抛异常,safeParse 返回一个判别联合 { success: true, data } | { success: false, error }。在边界上我们通常希望失败是「可预期的返回值」而不是异常,这一点和 错误类型与 Result 模式
的思路一致。
服务端同理,用同一份 schema 校验入参,API 契约与边界数据校验 里有更完整的服务端写法。
字段增删算不算破坏性变更
契约定下来之后,真正的日常是「改字段」。下面这张表是团队里最该贴到墙上的:
| 变更 | 兼容 | 处理方式 |
|---|---|---|
| 新增可选字段 | 是 | 消费方必须容忍 undefined |
| 新增必填字段 | 否 | 升大版本,或新增一条路由 |
| 删除字段 | 否 | 先标 @deprecated,观察调用量再删 |
字段类型收窄(string → 字面量联合) | 否 | 视为破坏性变更 |
字段类型放宽(字面量联合 → string) | 是 | 消费方的 switch 会失去穷尽性检查 |
| 修改字段含义 | 否 | 当成一个新字段,双写过渡 |
这里有个 TypeScript 特有的坑:把类型「收紧」也是一种破坏性变更。你在服务端把 status: string 改成 status: "active" | "inactive",前端的代码可能立刻编译失败——因为它原本依赖 status 是宽类型。版本号规则(下一节会展开)要求这种改动走大版本。
还有一个 schema 层面的细节值得单独说:服务端校验应该用 z.object({...}).strict(),拒绝多余字段以防脏数据入库;而客户端校验应该允许多余字段通过(Zod 默认行为是剥离而非报错),这样服务端先上线新字段时,旧前端不会因为「多了不认识的键」而崩掉。服务端严格、客户端宽松,这是让前后端能独立部署的关键。
统一错误契约
错误是最容易失控的部分:有的接口返回 { error: "..." },有的返回 { message: "..." },有的干脆返回 200 加 null。解决办法是把错误码收敛成一个字面量联合,再用 Record 强制每个码都有 HTTP 状态映射:
// packages/contracts/src/errors.ts
export type ErrorCode =
| "NOT_FOUND"
| "UNAUTHORIZED"
| "VALIDATION_FAILED"
| "INTERNAL";
// Record 保证「每个错误码都有一条映射」,漏一个就编译报错
export const HTTP_STATUS: Record<ErrorCode, number> = {
NOT_FOUND: 404,
UNAUTHORIZED: 401,
VALIDATION_FAILED: 422,
INTERNAL: 500,
};
Record<ErrorCode, number> 的强制力来自 内置工具类型全解
里讲过的映射类型:键集合被钉死为 ErrorCode,少写一个键就报 Property 'INTERNAL' is missing。新增错误码时编译器会把你带到所有需要同步的地方——这正是我们在 17.1 里反复强调的「让类型系统替你发现问题」。
三类序列化陷阱
契约写对了,还有一类问题藏在 JSON.stringify 里。它们共同的特征是:类型看起来对,序列化后不对。
| 陷阱 | 现象 | 契约层怎么处理 |
|---|---|---|
Date | 序列化成字符串,反序列化不会还原成 Date | 契约里就用 string(ISO 8601),转换放在 DTO 函数里 |
bigint | JSON.stringify 直接抛异常 | 契约里用 string 表示,需要运算时再转 |
undefined | 序列化后该字段整个消失 | 用 null 表达「空」,别用 undefined |
NaN / Infinity | 静默变成 null | 在 schema 里用 .finite() 挡掉 |
| 循环引用 | 序列化抛异常 | 只允许传 DTO,不允许传领域对象 |
bigint 那条的错误长这样,很多人在处理订单号或雪花 ID 时都踩过:
JSON.stringify({ orderId: 9007199254740993n });
// TypeError: Do not know how to serialize a BigInt
顺带解释一个高频现象:JavaScript 的 number 是双精度浮点,安全整数上限是 2^53 - 1。后端数据库用 BIGINT 存 ID 时,超出这个范围的 ID 传到前端会精度丢失——9007199254740993 会变成 9007199254740992。所以 ID 在契约里一律用 string,这不是风格偏好,而是正确性问题。
常见坑
- 用
as代替校验。只要有一处as SomeDto,前面的所有类型安全都白费了。边界上的数据必须经过 schema。 - 契约包被前端引入了后端实现。
packages/contracts只应导出类型和 schema,不要让它import数据库或框架代码,否则前端打包体积会莫名其妙地变大。 import type与值导入混用。只导出类型时用import type可以让它在编译后彻底消失;但 schema 是值,必须用普通import。两者的区别在 ESM/CJS 互操作与 moduleResolution 里有详细说明。- 契约没有测试。契约包应当有测试:给定一份真实的响应样例,断言 schema 能通过。这类「消费者驱动的契约测试」思路可以看 契约测试 ,它专门解决「前后端各自都测过了,但接起来就崩」的问题。
如果你想看这套思路在框架层面的完整实现,类型安全 API 的实践指南 和 Zod 校验实践 是两个很好的延伸;而 API 设计与契约治理 与 API 版本化策略 则从组织层面讨论了契约怎么管、版本怎么排——那正是下一节的主题。
小结
这一节我们把「两份类型定义」这个尾巴剪掉了,方法可以归纳成四条:
- 定义只有一处。要么放共享包,要么从 schema 生成,绝不允许手工复制两份。
- 响应带判别字段。
ApiResponse<T>让调用方在ok上收窄,杜绝「忘了判空」。 - schema 是唯一事实来源。
z.infer让类型和校验同源,从机制上杜绝漂移;边界上的as一律换成safeParse。 - 兼容性有明确的判定表。加可选字段安全,删字段、加必填字段、收紧类型都不安全。
还有一个容易被忽略的结论:服务端严格、客户端宽松的校验策略,是让前后端能各自独立发布的必要条件。契约的兼容性一旦定下来,它就直接决定了你能用什么节奏发布——而发布本身,是下一节要讲的事。
下一节我们走完最后一公里:把代码真正送上生产。我们会看到构建产物与运行时的差异、CI 流水线里类型检查该放在第几步、语义化版本号在 TypeScript 项目里有什么特殊含义,以及金丝雀、蓝绿、功能开关这三种发布策略各自的回滚代价。
阅读导航:上一节:17.1 项目结构与分层设计 · 下一节:17.3 部署、发布与版本演进 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。