类型不只是注解,而是接口契约的一部分。本文从联合类型、可辨识联合、类型收窄、泛型约束、类型边界与运行时校验等角度,讨论如何用TypeScript设计出可读、可维护、可演进的类型,而不是把类型写成另一种负担。
很多团队引入 TypeScript 之后,类型确实变多了,但代码并没有变得更好维护。类型写成 any 或 unknown 再强转,接口定义和实际数据结构脱节,联合类型一收窄就报错,泛型复杂到没人愿意读。问题不在于 TypeScript 不够强,而在于类型被当成了“给编译器看的注解”,而不是“接口契约的一部分”。好的类型设计,应该让非法状态无法表示,让合法路径自然浮现,让调用者在不读实现的情况下就知道怎么用。下面从几个具体的类型设计场景展开。
联合类型:先描述可能性,再约束行为
联合类型是 TypeScript 中最基础也最有力的工具之一。它描述“这个值可能是 A,也可能是 B”。但很多团队只把它当作参数宽泛化手段,比如 string | number,却没有利用它来建模业务状态。
一个典型场景是请求状态。很多人会写成这样:
typescript
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}这个类型允许 loading: true 同时 data 存在,也允许 loading: false 但既没有 data 也没有 error。它没有表达“加载中、成功、失败”是互斥状态。更好的写法是用可辨识联合:
typescript
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };区别在于,后者让非法状态无法表示。当 status 是 'success' 时,data 一定存在;当 status 是 'error' 时,不可能访问到 data。编译器会强制你在使用前先判断状态,而不是靠记忆和注释。
建模方式 | 非法状态 | 访问安全 | 可读性 |
|---|---|---|---|
布尔标志组合 | 容易产生矛盾状态 | 需要人工判断 | 靠注释理解 |
可选字段堆叠 | 字段缺失含义模糊 | 需要非空断言 | 容易误用 |
可辨识联合 | 无法表示非法状态 | 收窄后自动安全 | 类型即文档 |
联合类型的设计原则是:先列出所有合法状态,再为每个状态定义必要字段。不要把互斥字段塞进同一个接口,再用可选标记来“兼容”。
可辨识联合:让收窄成为自然路径
可辨识联合的核心是每个成员都有一个共同的、字面量类型的字段作为判别键,通常是 type、kind、status 或 tag。这个字段让 TypeScript 能在 switch 或 if 中自动收窄类型。
typescript
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}在这个例子中,进入 case 'circle' 后,shape 自动收窄为 { kind: 'circle'; radius: number },不需要类型断言。如果后续新增 'ellipse' 类型,switch 如果没有覆盖,TypeScript 会在 strict 模式下提示遗漏。这就是类型驱动开发的价值:类型变化会推动代码更新,而不是等到运行时才发现。
可辨识联合适合建模:状态机、事件、API 响应、表单字段、命令、消息、AST 节点、权限角色。凡是“多种形态、互斥字段”的场景,都可以考虑用它替代布尔标志和可选字段。
类型收窄:不要用断言代替判断
类型收窄是 TypeScript 根据控制流自动缩小类型范围的能力。常见手段包括 typeof、instanceof、in、字面量比较、自定义类型守卫。收窄做得好,代码不需要 as;收窄做得差,就会到处是 as any。
一个常见错误是用类型断言跳过收窄:
typescript
function handle(value: string | number) {
const str = value as string;
return str.toUpperCase();
}这段代码在 value 是数字时会运行时崩溃,但编译器被断言骗过。更好的写法是用 typeof 收窄:
typescript
function handle(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value.toFixed(2);
}自定义类型守卫适合复杂结构:
typescript
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
}类型守卫的价值在于,它把运行时检查和编译时收窄绑定在一起。但守卫本身需要被测试,否则可能返回错误结果,导致类型安全只是假象。
泛型约束:从类型体操回到可读接口
泛型是 TypeScript 最容易被滥用的特性。很多代码库里有类似 T extends Record<string, unknown> 或三层嵌套条件类型的泛型,读起来像在解谜。泛型的目标不是展示类型技巧,而是表达“这个函数对多种类型都成立,且这些类型共享某些约束”。
一个实用原则是:泛型参数应该出现在至少两个位置。如果 T 只在参数中出现一次,在返回值中不出现,那它可能不需要是泛型。如果 T 只在返回值中出现,调用者无法推断,那可能需要重新设计。
typescript
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}这里 T 和 K 都有明确作用:T 是元素类型,K 是键,返回值是 T[K][]。调用者能获得精确类型,而不是 any[]。
泛型约束应该尽量简单。extends 用来表达必要条件,而不是用来做复杂运算。如果约束超过两层嵌套,考虑拆成命名类型或改用重载。可读的泛型比强大的泛型更有价值,因为大多数团队成员不是类型体操选手。
类型边界:外部数据不能信任
TypeScript 的类型只在编译时存在,运行时全部擦除。来自 API、localStorage、URL 参数、表单输入、第三方 SDK 的数据,在运行时可能是任何东西。直接把 response.json() 断言为 User,是一种危险的类型幻觉。
类型边界的设计应该分两层:外部数据用 unknown 接收,经过运行时校验后再收窄为内部类型。
typescript
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const raw: unknown = await res.json();
if (!isUser(raw)) {
throw new Error('Invalid user response');
}
return raw;
}运行时校验可以用 zod、io-ts、valibot 等库,也可以手写类型守卫。关键是:类型断言不能替代校验。断言只是让编译器闭嘴,校验才能保证运行时安全。
数据来源 | 接收类型 | 校验方式 | 输出类型 |
|---|---|---|---|
API 响应 |
| schema 校验 | 内部类型 |
localStorage |
| JSON 解析 + 守卫 | 内部类型 |
URL 参数 |
| 解析 + 范围检查 | 内部类型 |
表单输入 |
| 字段级校验 | 内部类型 |
第三方 SDK | 厂商类型 | 边界适配 | 内部类型 |
内部模块之间可以信任类型,但跨越边界时必须校验。这条规则能避免大量“类型正确但运行崩溃”的问题。
类型即文档:命名与组织
类型命名直接影响可读性。User、Order、PaymentMethod 比 IUser、TOrder、PaymentMethodType 更清晰。接口和类型别名不必加 I 前缀,这是 Java 习惯,不是 TypeScript 惯例。类型应该描述领域概念,而不是实现细节。
类型的组织方式也很重要。把类型按领域放在一起,而不是按“接口”“枚举”“常量”分散在多个目录。一个 user.ts 可以同时包含 User、UserRole、UserStatus、CreateUserInput、UpdateUserInput,因为它们共同描述用户领域。调用者在一个文件里就能理解全貌。
类型导出应该克制。不是所有类型都需要导出。内部辅助类型留在模块内,公共契约类型才导出。导出越多,耦合面越大,重构越困难。
迁移策略:从 any 到类型安全
存量项目引入 TypeScript 时,不必追求一次性全量类型化。更现实的路径是渐进迁移:
先开启
strict中的关键选项,如strictNullChecks、noImplicitAny。新代码必须类型完整,旧代码允许
any但标注TODO。从边界开始类型化:API 响应、配置、公共组件 props。
用
unknown替代any,强制校验。为高频数据结构定义领域类型,逐步替换内联类型。
用
@ts-expect-error替代@ts-ignore,让抑制可追踪。定期清理类型断言,用收窄替代。
迁移的目标不是“零 any”,而是“关键路径类型安全”。把精力放在边界、公共接口和高频变更区域,收益最大。
类型设计没有终点
TypeScript 的类型系统很强大,但强大不等于复杂。好的类型设计应该让调用者觉得自然,让维护者觉得清晰,让重构者觉得有安全感。它不需要炫技,只需要准确表达业务约束,让非法状态无法表示,让合法路径自然浮现,让边界数据必须校验。
类型不是写给编译器看的,而是写给下一个读代码的人看的。当类型成为团队共享的契约,它就不再是负担,而是协作的基础设施。
演示站内容均来自互联网,如有侵权,请与我联系
文章不错?点个赞呗~