类型定义是TypeScript项目的骨架。一个项目里如果到处散落着重复定义的类型,改一处漏三处,API联调时前后端互相推诿“你这字段名不对”,本质上都是类型没管好。共享类型这件事,做的不是代码复用,是让团队对数据结构达成共识——这个共识一旦建立,编译器就是最严格的契约检查官。
前后端分离的典型场景里,后端返回一个用户对象,前端用 interface 重新定义一遍。字段名从 user_id 改成 userId,address 突然变成 Address,遇到接口升级,前端忘了同步类型定义,线上报错才发现字段对不上。这根本不是代码bug,是契约管理出了问题。把核心数据结构提取为共享类型包,前后端引用同一份类型定义,后端改了什么字段,前端编译阶段就报错,根本等不到上线。
举个例子,一个用户信息接口的共享类型定义:
// shared-types/user.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}
export interface UserCreatePayload {
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
export interface UserUpdatePayload {
name?: string;
email?: string;
role?: 'admin' | 'editor' | 'viewer';
}后端控制器直接引用这个类型包,前端发起请求时也用同样的类型做参数约束。当后端在 User 接口里新增一个 phone 字段,前端如果没同步更新引用,tsc --noEmit 直接报类型不匹配。这种“编译即校验”的机制,比任何code review都可靠。
共享类型的落地方式没有银弹,取决于团队规模和项目结构。
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 本地共享包 | monorepo 项目 | 改动即时生效,依赖管理简单 | 仅限于单仓库项目 |
| npm 私有包 | 多仓库协作 | 版本化管理,跨项目复用 | 每次改动需发布新版本 |
| Git Submodule | 跨组织共享 | 直接引用源码,无构建步骤 | 更新依赖手动操作,容易遗漏 |
monorepo 场景下,用 pnpm workspace 或 turborepo 管理本地包是最干净的方案。在 packages/shared-types 目录下维护所有共享类型,各子项目通过 workspace 协议引用:
{
"dependencies": {
"@your-org/shared-types": "workspace:*"
}
}多仓库场景下,把共享类型发布为 npm 私有包更合适。但这里有一个关键细节:类型包发布时必须带上 .d.ts 文件,而且版本号遵循语义化版本规范。一旦后端改了接口字段类型,必须发布一个 major 版本,前端升级依赖时就会意识到有 breaking change。
第一个陷阱是过度抽象。有人恨不得把所有类型都塞进共享包,连一个按钮组件的 props 类型都要提取出来。结果共享包膨胀到几千行,改动一个类型影响十几个项目,最后谁都不敢改。类型共享的边界很清楚:只共享跨项目边界流通的数据结构,也就是 API 请求/响应类型、数据库实体类型、事件消息体类型。组件内部的 props 类型归属于组件本身,不是共享的范畴。
第二个陷阱是类型定义与运行时校验脱节。TypeScript 的类型在编译后全部消失,运行时拿到的数据到底符不符合类型定义,编译器管不了。这就是为什么需要 Zod、io-ts 这类运行时校验库配合共享类型使用:
import { z } from 'zod';
// 共享类型定义
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string().datetime(),
});
// 从 schema 推导出 TypeScript 类型
export type User = z.infer<typeof UserSchema>;用 Zod schema 定义类型,既能生成 TypeScript 类型,又能在运行时校验 API 返回的数据。一旦后端返回了不符合 schema 的数据,UserSchema.parse(data) 直接抛出明确错误,不需要等到业务逻辑里才发现某个字段是 undefined。
第三个陷阱是忽略类型的版本兼容性。API 升级时,后端可能同时返回新旧两种格式的数据。共享类型如果没有考虑到兼容性,前端一旦升级类型包,旧接口就报错。正确的做法是在类型定义里保留可选字段,让新旧版本共存:
// v2 版本,保留 v1 的 oldField 为可选
export interface User {
id: string;
name: string;
email: string;
newField: string; // v2 新增必填字段
oldField?: string; // v1 遗留字段,标记为可选
}等所有消费者都升级到 v2 之后,再移除 oldField。这种渐进式迁移策略,避免了“一刀切”升级带来的大面积故障。
当共享类型包被几十个项目依赖时,改动一个类型发一次 major 版本,整个团队都得跟着升级。这个摩擦成本不能靠行政命令解决,得靠自动化的类型变更检测。在 CI 流程里跑一个类型兼容性检查脚本,判断本次改动是 patch、minor 还是 major 变更。如果检测到 breaking change,自动在 PR 上打标签,提醒 reviewer 评估影响范围。
一个简单的做法是借助 typescript 编译器 API 对比两个版本的 .d.ts 文件,检查是否存在删除字段、修改字段类型、缩小联合类型范围等操作。这类脚本写起来不复杂,但能避免很多“不小心改了类型导致下游炸掉”的线上事故。
共享类型这件事,说到底是一种工程纪律。它不要求你写更多的代码,而是要求你把散落在各处的类型定义收敛到一处。这个收敛动作本身,就是团队对数据结构达成共识的过程。共识一旦建立,编译器就成了强制执行合约的机器,谁也别想蒙混过关。
评论
加载中...