类型不只是注解,而是接口契约的一部分。本文从联合类型、可辨识联合、类型收窄、泛型约束、类型边界与运行时校验等角度,讨论如何用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 响应

unknown

schema 校验

内部类型

localStorage

string | null

JSON 解析 + 守卫

内部类型

URL 参数

string | null

解析 + 范围检查

内部类型

表单输入

FormData

字段级校验

内部类型

第三方 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 的类型系统很强大,但强大不等于复杂。好的类型设计应该让调用者觉得自然,让维护者觉得清晰,让重构者觉得有安全感。它不需要炫技,只需要准确表达业务约束,让非法状态无法表示,让合法路径自然浮现,让边界数据必须校验。

类型不是写给编译器看的,而是写给下一个读代码的人看的。当类型成为团队共享的契约,它就不再是负担,而是协作的基础设施。

© 版权声明
演示站内容均来自互联网,如有侵权,请与我联系

文章不错?点个赞呗~