首页 / 文章

六种将类型转化为真正错误预防机制的 TypeScript 技巧

了解如何通过标记联合、永不检查、未知类型、派生类型以及带标签的标识符,让 TypeScript 在编译时而非生产环境中捕获真正的错误。

发布者:REACTAPP.TOP阅读约 7 分钟1627 词

来源: TypeScript Can Already Do This. Almost Nobody Uses It.

一个项目可以在 package.json 中列出 TypeScript,定义包含十几个可选字段的接口,却仍然会因为“无法读取未定义对象的属性”而崩溃。安装编译器并不等同于它能真正为你解决问题,而且仅在每个变量后加上 : string 也几乎起不到作用。以下六种方法是经验丰富的团队用来让类型检查器发挥实际作用的手段,每种方法都附有代码示例及背后的原理说明。

使用 satisfies 而非注解来验证对象

确保配置对象具有正确结构的常规方法是为其添加注解:

const config: Record<string, number> = {
  timeout: 3000,
  retries: 3,
};
config.timeout.toFixed(2); // fine

虽然这段代码可以编译,但该注解会取代TypeScript原本推断出的类型。此时变量的类型恰好为Record<string, number>,因此编译器不再知道该对象实际包含哪些键。config.timeout仅被视作“某个字符串键下的数字”,类似config.timout这样的拼写错误也能被接受且不会引发警告;而当启用noUncheckedIndexedAccess时,所有读取的属性类型都会变为number | undefined。

自TypeScript 4.9起引入的satisfies运算符可以检查对象是否符合特定类型,但不会替换已推断出的类型:

const config = {
  timeout: 3000,
  retries: 3,
} satisfies Record<string, number>;

config.timeout; // TypeScript still knows this is literally 3000

这样可以实现验证(字符串类型会被拒绝),同时保持精确的结构:TypeScript 能确保对象恰好包含 timeout 和 retries 这两个属性,拼写错误的键则会引发错误。需要纠正一个常见误解,包括代码片段中的注释:仅使用 satisfies 是无法将 timeout 的类型保持为字面量 3000 的,因为可变对象的属性类型仍会被放宽为 number。如果还需要保留这些字面量值,应将两个操作符结合使用,即 as const satisfies Record<string, number>,这样既能将对象设为只读,又能保留所有字面量值。多年来由于这是唯一的选项,许多开发者仍然习惯使用这种注解。

模型变体应作为带标签的联合类型,而非可选字段

当一个值可以有多种形式时,一种诱人的捷径是将所有字段都设为可选:

interface ApiResponse {
  data?: User[];
  error?: string;
  loading?: boolean;
}

此时该类型允许任何组合,包括同时出现data和error,或者当loading为false时两者都不出现。只允许一次仅存在一种状态这一规则存在于人们的意识中,编译器无法强制执行它。

判别式联合(带标签的联合)能够体现这一规则。每个变体都携带一个字面量的status标签,以及仅属于该变体的字段:

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />; // data exists, guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;
  }
}

在每个 case 中,TypeScript 会将 response 筛选为对应的类型。由于其他类型根本不存在 data 属性,因此只有 success 分支能够访问 response.data。这类“此处是否有该属性?”的错误在代码运行前就已消失,同样的机制也适用于 reducer 操作、表单状态以及 API 返回结果。

使用 never 使缺失的 case 导致构建失败

带标签的联合类型仍存在一个缺陷。假设几个月后有人新增了 'forbidden' 状态,由于没有强制要求更新所有基于 ApiResponse 的 switch 语句,新的 case 会默默地被忽略,最终成为生产环境中的错误。

完整性检查可以填补这一空白。default 分支会将筛选后的值赋给类型为 never 的变量:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />;
    case 'error':
      return <ErrorBanner message={response.error} />;
    default:
      const exhaustiveCheck: never = response;
      throw new Error(`Unhandled status: ${exhaustiveCheck}`);
  }
}

当所有情况都得到处理后,response 就没有理由再出现在 default 分支中,因此它的类型为 never,这样的赋值操作可以正常编译。如果添加了 'forbidden' 而没有对应的 case 分支,response 的类型就会变成 { status: 'forbidden' },而该类型无法赋值给 never,从而导致编译失败,并直接指出被忽略的分支。此外,throw 语句还能在出现意外数据时保护程序在运行时的稳定性。将 default 分支的内容用大括号包裹,还能让那些会标记 case 子句中声明语句的代码检查工具满意。

在应用程序的边界处使用 unknown 类型

any 会关闭类型检查。而 unknown 则保持类型检查开启,并要求你在使用某个值之前先证明其类型。在不可信数据进入系统的场景中——比如 API 响应、JSON.parse 的结果以及 webhook 的负载数据——这正是你所需要的行为。

在此处,fetch 辅助函数会刻意返回 Promise<unknown>。直接读取 .name 会引发编译错误,只有在类型保护机制内才能合法使用。

async function fetchUser(id: string): Promise<unknown> {
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
  raw.name; // fine, narrowed to User inside the guard
}

isUser是一种用户自定义的类型守卫,其签名为function isUser(x: unknown): x is User,其实现必须在运行时真正检查数据的结构。使用unknown时,编译器会在有代码验证数据之前拒绝访问其属性。而使用any时,验证是可选的,通常会在接收到格式错误的响应且组件误以为某个字段必然存在时才进行验证。对于较大的数据量,像Zod这样的模式库可以同时生成运行时检查逻辑和类型定义。更多相关模式可参阅六种用于替代any的类型安全方案。

从代码中推导类型而非维护副本

如果某个函数已经返回了值,就无需为该结果单独编写手动定义的接口。TypeScript 可以自动提取它:

function createOrderPayload(order: Order) {
  return {
    id: order.id,
    total: order.total,
    lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
  };
}

type OrderPayload = ReturnType<typeof createOrderPayload>;

ReturnType<typeof createOrderPayload> 会读取函数推断出的返回类型。修改函数后,OrderPayload 在所有使用该类型的地方都会随之更新。而手动维护的接口则容易出错:经过几次重构后,它可能会声称存在某个函数早已不再返回的字段,而编译器仍会信任这个过时的接口。

同样的原理也适用于那些并非你拥有的库中的组件。React.ComponentProps<typeof Button>会根据第三方Button自身的类型定义,返回该组件所接受的精确属性列表,因此当库新增属性时无需更新任何副本。类似的辅助类型如Parameters、Awaited以及索引访问类型(OrderPayload['lineItems'][number])进一步扩展了这一方法;更多内容可参阅TypeScript的内置实用类型以获取概览。

专有的原始类型防止ID被替换

默认情况下,UserId和OrderId都是普通字符串,因此将订单ID传递给期望接收用户ID的函数时编译不会出错。不过查询结果会为空,进而需要有人花费时间去排查原因。

品牌标识通过一种虚拟属性与原始类型相交,从而使两者变得不兼容:

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId

在运行时根本不存在 __brand 属性;它仅存在于类型系统中,这已足以让 TypeScript 将这两种类型视为不同的类型。值是通过一个简单的构造函数或验证函数创建的(示例中的 as OrderId 转换就起到了这个作用),从那时起,任何意外的混淆都会在编译阶段引发错误,而不会产生任何运行时开销。请将所有类型转换都放在那些构造函数中,这样“品牌标识”才能真正表示“该值已经过验证”。

总结

这一切都不需要新的框架或库;只需合理运用你已有的 TypeScript 即可:

  • satisfies 可在不丢弃推断出的类型结构的情况下进行验证;当字面量很重要时,可添加 as const。
  • 标记联合类型使得不可能的状态无法被表示。
  • never检查会将被忽略的情况转化为构建错误。
  • 边界处的unknown值要求在使用前进行验证。
  • 派生类型会与生成这些值的代码保持同步。
  • 品牌标识用于区分语义上不同的字符串。
  • 请选择一种技术并在下一次拉取请求中应用它。每种方法都能将一类错误从运行时转移到编译时,这正是安装TypeScript与让它真正保护代码之间的实际区别。