类型收窄与类型守卫

掌握 TypeScript 类型收窄的完整机制,包括 typeof/instanceof/in 收窄、自定义类型谓词和判别联合类型的工程实践。

类型收窄与类型守卫

类型收窄的本质

TypeScript 的类型收窄(Type Narrowing)是控制流分析(Control Flow Analysis)的核心能力。编译器通过分析代码中的条件分支,自动将变量的类型从宽泛收窄到精确。这使得开发者可以安全地处理联合类型,而无需类型断言。

面试定位:类型收窄能力直接反映开发者对 TypeScript 类型系统的理解深度。面试中常出现"如何处理这个联合类型"的实际场景题,考察的就是收窄策略的选择。


内置收窄机制

typeof 收窄

typeof 是处理原始类型联合的首选方式:

function format(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // TypeScript 知道是 string
  }
  if (typeof value === "number") {
    return value.toFixed(2); // TypeScript 知道是 number
  }
  return value ? "Yes" : "No"; // TypeScript 知道是 boolean
}

typeof 能识别的类型"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"

注意 typeof null === "object" 这一 JavaScript 历史遗留问题,收窄时需要额外处理:

function process(value: object | null) {
  if (typeof value === "object" && value !== null) {
    // 现在确定是 object
  }
}

instanceof 收窄

用于类和构造函数产生的对象:

function handleError(error: unknown) {
  if (error instanceof TypeError) {
    console.error("Type error:", error.message);
  } else if (error instanceof RangeError) {
    console.error("Range error:", error.message);
  } else if (error instanceof Error) {
    console.error("Generic error:", error.message);
  } else {
    console.error("Unknown error:", error);
  }
}

in 操作符收窄

通过检查属性是否存在来收窄类型:

interface Fish { swim(): void }
interface Bird { fly(): void }

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    animal.swim(); // TypeScript 知道是 Fish
  } else {
    animal.fly();  // TypeScript 知道是 Bird
  }
}

真值收窄(Truthiness Narrowing)

function printName(name: string | null | undefined) {
  if (name) {
    console.log(name.toUpperCase()); // 排除 null 和 undefined
  }
}

陷阱:空字符串 ""0 是假值,真值收窄会误排除它们:

function process(value: string | null) {
  if (value) {
    // value 是 string,但空字符串被排除了
  }
  // 更精确的方式
  if (value !== null) {
    // value 是 string,包括空字符串
  }
}

等值收窄

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // x 和 y 都被收窄为 string(唯一的公共类型)
    console.log(x.toUpperCase());
  }
}

判别联合类型(Discriminated Unions)

判别联合类型是 TypeScript 中最重要的模式之一,通过共享的字面量属性实现编译器可验证的穷举处理:

// 定义判别联合
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; 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 "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

exhaustive check 模式

确保所有联合成员都被处理,新增成员时编译器会报错:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);
      // 如果 Shape 新增了成员但未处理,这里会报编译错误
  }
}

实际应用:状态机

type AuthState =
  | { status: "idle" }
  | { status: "authenticating"; provider: string }
  | { status: "authenticated"; user: User; token: string }
  | { status: "error"; error: Error; retryCount: number };

function renderAuth(state: AuthState) {
  switch (state.status) {
    case "idle":
      return <LoginForm />;
    case "authenticating":
      return <Spinner provider={state.provider} />;
    case "authenticated":
      return <Dashboard user={state.user} />;
    case "error":
      return <ErrorView error={state.error} canRetry={state.retryCount < 3} />;
  }
}

自定义类型谓词(Type Predicates)

基础语法

当内置收窄不够用时,可以定义自定义类型守卫函数:

interface Cat { meow(): void; purr(): void }
interface Dog { bark(): void; fetch(): void }

function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

function interact(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.purr(); // TypeScript 知道是 Cat
  } else {
    animal.fetch(); // TypeScript 知道是 Dog
  }
}

类型谓词与数组过滤

类型谓词在数组操作中特别有用:

// 过滤 null/undefined
function isNotNull<T>(value: T | null | undefined): value is T {
  return value != null;
}

const items = [1, null, 2, undefined, 3];
const clean = items.filter(isNotNull);
// 类型:number[](而非 (number | null | undefined)[])

// 过滤特定类型
interface Success { type: "success"; data: unknown }
interface Failure { type: "failure"; error: Error }
type Result = Success | Failure;

function isSuccess(result: Result): result is Success {
  return result.type === "success";
}

const results: Result[] = [/* ... */];
const successes = results.filter(isSuccess);
// 类型:Success[]

类型谓词的风险

类型谓词将类型安全的责任从编译器转移到开发者。如果谓词实现不正确,编译器不会发出警告:

// 危险:实现与类型声明不匹配
function isString(value: unknown): value is string {
  return typeof value === "number"; // 实现错误!但编译不报错
}

const val: unknown = 42;
if (isString(val)) {
  val.toUpperCase(); // 运行时崩溃
}

最佳实践:为类型谓词编写单元测试,验证其在边界条件下的正确性。


赋值收窄与控制流分析

赋值收窄

let value: string | number;

value = "hello";
console.log(value.toUpperCase()); // TypeScript 知道是 string

value = 42;
console.log(value.toFixed(2)); // TypeScript 知道是 number

控制流的限制

TypeScript 的控制流分析不跨函数边界:

const items: string[] = [];

function addItem() {
  items.push("hello");
}

addItem();
// TypeScript 不知道 items 不再为空
// items.length 的类型仍然是 number,不是 1

闭包中的收窄信息也会丢失:

function example(value: string | null) {
  if (value !== null) {
    setTimeout(() => {
      // TypeScript 仍然认为 value 可能是 null
      // 因为闭包捕获的是变量引用,值可能在回调执行前改变
      console.log(value.toUpperCase()); // ❌ 报错
    }, 100);
  }
}

解决方式:使用 const 绑定或断言函数。


断言函数(Assertion Functions)

TypeScript 3.7+ 引入了 asserts 关键字,使函数能像类型守卫一样影响控制流:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function process(input: unknown) {
  assertIsString(input);
  // 此后 input 的类型为 string
  console.log(input.toUpperCase());
}

与类型谓词的区别:类型谓词通过布尔返回值收窄,断言函数通过不抛出异常来收窄。


面试高频问题

Q: TypeScript 有哪些类型收窄方式?

回答要点:主要包括 typeof 收窄(原始类型)、instanceof 收窄(类实例)、in 操作符收窄(属性检测)、等值收窄、真值收窄、判别联合类型(字面量属性)、自定义类型谓词(value is Type)、断言函数(asserts value is Type)。选择哪种方式取决于要收窄的类型结构——原始类型用 typeof,类实例用 instanceof,对象联合用判别属性或 in 操作符。

Q: 什么是 exhaustive check?为什么重要?

回答要点:exhaustive check 利用 never 类型确保联合类型的所有成员都被处理。当 switch/if 覆盖了所有分支后,剩余类型为 never;如果有遗漏,编译器会在 never 赋值处报错。这在状态机、Redux reducer、API 响应处理中至关重要——它保证新增联合成员时,编译器会在所有需要处理该成员的地方产生错误,防止遗漏。

Q: 类型谓词有什么风险?

回答要点:类型谓词(value is Type)是对编译器的承诺——开发者声称函数为 true 时,参数确实是指定类型。但编译器不验证实现与声明的一致性。如果谓词实现有误,会导致类型系统被"欺骗",产生运行时错误。因此,类型谓词应保持简单、可测试,并在关键路径上辅以运行时校验。


延展阅读