Union、Intersection 和 TypeScript 中的 Narrowing

1. 总览

Union 和 Intersection 是 TypeScript 中两个基本工具,用于描述比简单 interface 更灵活的数据。Narrowing 是一种技术,帮助编译器“理解”代码每个分支中值的具体类型,而不是强制你手动进行类型转换(as)。

本文从 union/intersection 的概念开始,经过常见的 narrowing 技术(typeof、instanceof、in、discriminated union),到编写自定义类型守卫。读完这篇文章后,你将知道何时使用 union,何时使用 intersection,以及如何避免“Object is possibly undefined”或代码中充斥 as any 的问题。

2. Union 类型

Union(|)意味着一个值可能属于多种中的任何一种。

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `支付方式:${method}`;
}

describe("card"); // OK
// describe("paypal"); // 编译错误:不在 union 中

PaymentMethod 只接受 3 个特定的字符串。如果传入其他值,TypeScript 会在编译时立即报告错误,而不是等到运行时。

Union 也适用于对象类型:

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

此时,Payment 类型的变量可能是 CashPayment 或 CardPayment,但如果还没有进行 narrowing,TypeScript 只允许你访问两者共有的字段(这里是 type 和 amount)。

function printAmount(p: Payment) {
  console.log(p.amount); // OK,共有字段
  // console.log(p.cardNumber); // 错误:在 CashPayment 上不存在
}

注意: Union 不会合并字段。要访问私有字段,你必须先进行 narrowing(见第 4 部分)。

3. Intersection 类型

Intersection(&)意味着一个值必须同时满足所有被组合的类型。

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "阮文甲",
  total: 150_000,
};

这里的 Order 同时具有 Identifiable、Timestamped 和额外对象字面量的所有字段。缺少任何字段,编译器都会报错。

Intersection 在你想将多个小的 mixin 组合成一个大 type 时很有用,类似于 OOP 中的 composition,但在类型级别上。

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. Intersection 与冲突的原始类型

如果 intersection 中的两个类型在原始类型上冲突,结果是 never:

type Impossible = string & number; // 类型为 never,没有值能满足

这种情况通常表示你错误地使用了 & 而不是 |。

4. Narrowing:在代码的每个分支中缩小类型范围

Narrowing 是编译器基于代码中的条件检查(if、switch、typeof…)自动推断更具体的类型的过程。

4.1. 使用 typeof 进行 Narrowing

用于由原始类型组成的 union。

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id 被 narrow 为 number
  }
  return id.toUpperCase(); // id 被 narrow 为 string
}

在 if 分支中,TypeScript 知道 id 是 number,所以允许调用 .toFixed()。在隐含的 else 分支中,id 仅剩下 string 类型。

4.2. 使用 instanceof 进行 Narrowing

用于由类组成的 union。

class ValidationError extends Error {
  constructor(public field: string) {
    super(`字段 ${field} 无效`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`验证字段错误:${err.field}`);
  } else {
    console.log(`其他错误:${err.message}`);
  }
}

err.field 只存在于 ValidationError 上,所以编译器只在已检查 instanceof 的分支中允许访问。

4.3. 使用 in 进行 Narrowing

用于 union 中的类型通过私有字段区分的情况(没有共有的 discriminant 字段)。

interface Dog {
  bark: () => void;
}

interface Cat {
  meow: () => void;
}

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // narrow 为 Dog
  } else {
    pet.meow(); // narrow 为 Cat
  }
}

in 操作符在运行时检查对象上是否存在属性,编译器使用这个结果来 narrow 相应的类型。

4.4. Discriminated Union —— 最安全的方式

这是实际代码中最常见的模式:添加一个共有的字面量字段(通常称为 kind 或 type)来区分 union 中的“分支”。

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`数据:${result.data}`);
      break;
    case "error":
      console.log(`错误:${result.message}`);
      break;
  }
}

编译器使用 status 字段作为“discriminant”:在 case "success" 中,result 被 narrow 为 SuccessResult;在 case "error" 中,narrow 为 ErrorResult。这是 TypeScript 对所有超过 2 个分支的复杂 union 推荐的方式,因为 switch 还支持穷尽性检查(见 4.6)。

4.5. 使用真值性和可选链进行 Narrowing

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone 从 string | undefined 被 narrow 为 string
    console.log(`拨打号码:${customer.phone}`);
  } else {
    console.log("客户没有电话号码");
  }
}

检查真值(if (customer.phone))排除了 undefined(以及空字符串,但对电话号码不重要)。

4.6. 使用 never 检查穷尽性

当在 union 中添加新分支但忘记处理时,你希望编译器立即报错,而不是运行时才出现 bug。在 default 分支中使用 never 类型:

function assertUnreachable(x: never): never {
  throw new Error(`未处理的情况:${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // 如果添加了新分支但忘记处理,编译时在此报错
  }
}

如果后来有人在 ApiResult 中添加了 PendingResult 但忘记添加相应的 case,default 分支中的 result 将不再是 never,编译器会立即报错 —— 无需等待 bug 在生产环境中出现。

5. 用户定义的类型守卫

当检查逻辑比一个简单表达式更复杂时,你可以编写返回 x is T 类型的函数来重用 narrowing。

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`支付时间:${order.paidAt.toISOString()}`);
  } else {
    console.log("未支付");
  }
}

order is PaidOrder 是一个类型谓词。编译器信任 isPaid 函数的结果来将 order narrow 为 PaidOrder 在 if 分支中,即使检查逻辑在单独的函数中。

6. 使用测试进行验证

Narrowing 是编译时特性,所以“测试”实际上就是运行 tsc 不报错。但你仍应编写对应的运行时行为的测试。

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("支付 narrowing", () => {
  it("正确计算现金支付的金额", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("正确计算卡支付的金额", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

运行 npx vitest run 得到结果:

✓ 支付 narrowing > 正确计算现金支付的金额
✓ 支付 narrowing > 正确计算卡支付的金额

Test Files  1 passed (1)
     Tests  2 passed (2)

7. 常见错误和何时不应该使用

  • 滥用 as 来避免 narrowing:如果你发现自己不断使用类型转换(as SomeType)来“取悦”编译器,很可能 union/type guard 的设计有问题。回到使用 discriminated union。
  • Intersection 两个 interface 有相同名称但不同类型的字段:TypeScript 会尝试合并,但如果类型冲突(例如一个是 string,另一个是 number),该字段会变成 never —— 很容易造成难以理解的错误。在设计 mixin 时始终保持字段名称统一。
  • Union 过于宽泛(例如 string | number | boolean | object):使得任何使用该类型的地方都需要 narrowing,代码混乱。如果 union 有超过 4—5 个不相关的分支,重新考虑领域模型 —— 可能应该拆分为多个独立的函数/overload。
  • 在 switch 中忘记 default 和 never:失去了穷尽性检查的优势,在扩展 union 时容易遗漏分支。
  • 在具有复杂原型链的对象上使用 in 操作符:in 会检查继承的属性,对于有深度继承的类可能会产生意外的 narrowing 结果。在这种情况下,instanceof 或 discriminant 字段更值得信赖。

注意: Narrowing 仅在 TypeScript 能够通过静态控制流证明时才能正常工作。如果你通过回调函数进行异步操作后将已 narrow 的值分配给另一个变量(setTimeout、promise .then),被 narrow 的类型可能会被“遗忘” —— TypeScript 不会在所有情况下完全追踪闭包边界。

8. 结论

Union 描述“多种中的一种”,intersection 描述“同时所有类型” —— 这两个工具是在 TypeScript 中构建精确领域模型的基础。Narrowing(typeof、instanceof、in、discriminated union、自定义类型守卫)帮助你安全地使用 union,而不需要手动类型转换。

实用规则:当有 2 个或更多分支时优先选择 discriminated union,始终为 switch 添加穷尽性检查(使用 never),避免 intersection 中字段名相同但类型不同的情况。正确应用,你会在编写代码时就捕获大量逻辑错误,在它们有机会在生产环境中运行之前。

typescripttype-safetyjavascriptfrontend