TypeScript 中的 type guard 和 assertion function

1. 概述

TypeScript 在 typeof、instanceof 或 === 方面的类型缩小(narrowing)做得很好。当检查逻辑变得更复杂,并且你将其提取为单独的函数时,编译器将不再“看到”检查结果。

本文介绍解决这个问题的两个工具。它们是 type guard(value is T)和 assertion function(asserts value is T)。读完后,你将知道如何编写它们、验证来自 API 的 unknown 数据、为它们编写测试,并识别它们悄悄欺骗编译器的情况。

2. 现有的 narrowing 及其限制

我们使用订单域作为贯穿全文的示例:

type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };

type Order = PendingOrder | PaidOrder | CancelledOrder;

function describe(order: Order): string {
  if (order.status === "paid") {
    return `订单 ${order.id} 支付于 ${order.paidAt.toISOString()}`;
  }
  return `订单 ${order.id} 处于状态 ${order.status}`;
}

Order 是一个 discriminated union,其判别字段为 status。在 if 分支中,TypeScript 知道 order 是 PaidOrder,所以访问 paidAt 是合法的。

现在你想重用这个条件,并将其提取为单独的函数:

function isPaid(order: Order): boolean {
  return order.status === "paid";
}

function describe(order: Order): string {
  if (isPaid(order)) {
    return `支付于 ${order.paidAt.toISOString()}`;
    //                    ^^^^^^ 错误
  }
  return "未支付";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
  Property 'paidAt' does not exist on type 'PendingOrder'.

isPaid 函数返回 boolean。boolean 类型不携带关于 order 的任何信息,所以编译器无法缩小类型。Type guard 正是为了解决这个问题而产生的。

3. 用户定义的 type guard

3.1. value is T 语法

你只需将返回类型从 boolean 更改为 type predicate:

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

function describe(order: Order): string {
  if (isPaidOrder(order)) {
    return `支付于 ${order.paidAt.toISOString()}`;
  }
  // 此处 order: PendingOrder | CancelledOrder
  return `状态:${order.status}`;
}

console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "缺货" }));
支付于 2026-10-01T00:00:00.000Z
状态:cancelled

解释:

  • order is PaidOrder 意味着:如果函数返回 true,则 order 的类型为 PaidOrder。
  • 在运行时,函数仍然只返回 true/false。is 部分仅在编译时存在。
  • 在 false 分支中,TypeScript 从 union 中排除 PaidOrder。order 变成 PendingOrder | CancelledOrder。
  • predicate 中的参数名称(order)必须与函数的某个参数名称相同。

3.2. 用 type guard 过滤数组

Type guard 特别适用于 Array.prototype.filter,因为 filter 有接收 predicate 的重载:

const orders: Order[] = [
  { status: "pending", id: "A1", total: 50 },
  { status: "paid", id: "A2", total: 120, paidAt: new Date() },
  { status: "paid", id: "A3", total: 80, paidAt: new Date() },
];

const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);

console.log(revenue);
200

paidOrders 的类型是 PaidOrder[] 而不是 Order[]。因此,之后的任何操作都可以访问 paidAt 而无需类型断言。

从 TypeScript 5.5 开始,编译器自动推断 arrow function 简单情况下的 type predicate。例如 orders.filter(o => o.status === "paid") 也会返回 PaidOrder[]。这种推断仅在表达式足够简单且 “true ⇔ 属于类型” 的关系是正确的情况下才有效。对于更复杂的逻辑,你仍然需要显式编写 type guard。

3.3. 检查 unknown 数据

Type guard 最实际的应用是检查外部数据,例如来自 API 的 JSON:

interface Customer {
  id: string;
  name: string;
  email: string;
}

function isCustomer(value: unknown): value is Customer {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "string" &&
    "name" in value && typeof value.name === "string" &&
    "email" in value && typeof value.email === "string"
  );
}

const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');

if (isCustomer(raw)) {
  console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM

从 TypeScript 4.9 开始,in 操作符将 value 缩小为 object & Record<"id", unknown>。因此你可以编写 value.id 而无需 as any。每个字段都会检查其存在性和数据类型。

3.4. 陷阱:type guard 说谎

编译器完全信任 type predicate。它不检查函数体是否真正匹配承诺:

function isCustomerBroken(value: unknown): value is Customer {
  return typeof value === "object" && value !== null; // 忘记检查字段
}

const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
  console.log(data.email.toUpperCase()); // 编译 OK,运行时崩溃
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

有一个更微妙的陷阱在 false 分支:

function isLargeAmount(value: string | number): value is number {
  return typeof value === "number" && value > 1000;
}

function format(value: string | number) {
  if (isLargeAmount(value)) return `大金额:${value}`;
  return value.toUpperCase(); // TS 认为 value: string
}

format(5); // TypeError: value.toUpperCase is not a function

函数为数字 5 返回 false。TypeScript 随后推断在其他分支中 value 不是 number。这是错误的。

注意: Type predicate 是一个双向关系。返回 true 必须意味着 “属于类型 T”。返回 false 必须意味着 “不属于类型 T”。不要将额外的业务条件(如 > 1000)混入 type guard。应将它们分离为单独的 boolean 函数。

4. Assertion function

4.1. asserts value is T

Type guard 强制你将代码包装在 if 中。有时你希望行为是 “如果错误则抛出错误,否则继续运行”。这就是 assertion function 的作用:

function assertIsCustomer(value: unknown): asserts value is Customer {
  if (!isCustomer(value)) {
    throw new TypeError("数据不是有效的 Customer");
  }
}

function greet(payload: unknown): string {
  assertIsCustomer(payload);
  // 从此行开始,payload: Customer
  return `你好 ${payload.name}`;
}

console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
你好 Minh
TypeError: 数据不是有效的 Customer

与 type guard 相比的区别:

  • 返回类型是 asserts value is Customer。函数体不 return 任何值,即函数返回 void。
  • 函数在条件错误时抛出错误。如果函数正常返回,TypeScript 认为条件为真。
  • 类型被缩小到调用后的整个代码部分,而不仅仅是 if 块。

4.2. asserts condition

第二种形式不与特定类型关联。它断言一个表达式是 truthy,类似于 Node.js 的 assert:

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function applyDiscount(order: Order, percent: number): number {
  assert(order.status !== "cancelled", `订单 ${order.id} 已取消`);
  // order: PendingOrder | PaidOrder → 有 total
  assert(percent > 0 && percent <= 50, "折扣必须在 (0, 50]");
  return order.total * (1 - percent / 100);
}

console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180

在行 assert(order.status !== "cancelled", ...) 之后,TypeScript 缩小 order 就像你刚刚编写 if 一样。因此 order.total 是有效的,即使 CancelledOrder 没有 total 字段。

4.3. TS2775 错误:缺少显式类型注解

这是编写 arrow function 形式的 assertion function 时最常见的错误:

const assertPositive = (n: number): asserts n => {
  if (n <= 0) throw new Error("必须为正");
};

assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.

编译器需要在声明时立即知道 assertPositive 的类型,不能依靠推断。有两种修复方法。第一种是使用 function 声明。第二种是为变量分配类型:

type AssertPositive = (n: number) => asserts n;

const assertPositive: AssertPositive = (n) => {
  if (n <= 0) throw new Error("必须为正");
};

此规则也适用于通过对象调用的方法,例如 validator.assertCustomer(x)。变量 validator 必须有显式声明的类型,例如 const validator: CustomerValidator = new CustomerValidator()。

注意: Assertion function 不能是 async。类型 Promise<asserts x is T> 不存在。如果需要异步检查,请先 await 获取数据,然后在结果上调用同步 assertion function。

5. 为 type guard 和 assertion function 编写测试

这是运行时逻辑,所以你需要像测试任何其他函数一样测试它。下面的示例使用 Vitest:

npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";

describe("isCustomer", () => {
  it("使用有效对象返回 true", () => {
    expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
  });

  it.each([
    null,
    "C1",
    { id: "C1", name: "Lan" },
    { id: 1, name: "Lan", email: "lan@example.com" },
  ])("使用 %j 返回 false", (input) => {
    expect(isCustomer(input)).toBe(false);
  });
});

describe("assertIsCustomer", () => {
  it("数据错误时抛出 TypeError", () => {
    expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
  });

  it("数据正确时缩小类型", () => {
    const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
    assertIsCustomer(payload);
    expect(payload.email).toBe("lan@example.com"); // 由于 narrowing 而编译通过
  });
});
npx vitest run
 ✓ customer.test.ts (7 tests)
 Test Files  1 passed (1)
      Tests  7 passed (7)

请注意否定的情况:缺少字段、错误的类型、null、基本值。Type guard 的大多数 bug 在于它在应该返回 false 时返回 true 的情况下,如第 3.4 节中的示例。最后的测试还在编译时起着测试的作用。如果某人将函数签名更改为 void,测试文件将无法编译。

6. 选择 type guard 还是 assertion function?

情景 应使用
数据错误是常见的,需要分支处理 Type guard
过滤数组、find、与 filter 配合 Type guard
数据错误是编程错误或违反合约,应该立即停止 Assertion function
检查函数头(前置条件)、避免嵌套 if Assertion function

两种工具通常一起使用。你编写 type guard 作为核心,然后将其包装在 assertion function 中,就像 assertIsCustomer 调用 isCustomer 一样。这样检查逻辑只存在于一个地方。

何时不应自己编写:

  • 大型或深度嵌套的 Schema。 为具有 30 个字段和嵌套数组的对象手动编写 isCustomer 很容易遗漏。像 Zod 或 Valibot 这样的验证库从同一个 schema 生成类型和检查,所以两者不会偏离。
  • 替代 as。 具有空函数体(return true)的 type guard 不比 value as Customer 更安全。它只是更巧妙地隐藏了问题。
  • 简单的 discriminated union。 如果 if (order.status === "paid") 已经足够清楚,不要仅仅为了有一个函数而提取它。

7. 总结

Type guard(value is T)允许你将类型检查逻辑封装到函数中,同时仍然在调用处保留 narrowing。它特别适合与 if 和 filter 搭配。Assertion function(asserts value is T 或 asserts condition)通过在条件错误时抛出错误来为调用后的整个代码部分缩小类型。它适合检查前置条件和输入数据。

两者都是编译器不验证的承诺。因此请记住三件事:

  • 保持 “true ⇔ 属于类型” 的关系。
  • 为 assertion function 声明显式类型。
  • 仔细测试否定的情况。
typescripttype guardassertion functionnarrowingtype safety