Chapter 05

类型缩窄:Guards 与断言

掌握 TypeScript 的类型缩窄机制,包括类型保护、可辨识联合与 satisfies 运算符

类型缩窄的工作原理

控制流分析(Control Flow Analysis)

TypeScript 的类型缩窄基于控制流分析——编译器追踪代码的每一条可能执行路径,在每个分支上计算变量的精确类型。这不是简单的语法检查,而是一种数据流分析算法,能处理复杂的条件嵌套、提前返回等场景。

类型守卫(Type Guard)
能让 TypeScript 在特定分支中缩窄类型的条件表达式。内置类型守卫:typeof、instanceof、in 运算符、真值检查(nullish 过滤)、相等比较(=== null)。自定义类型守卫通过 is 关键字声明返回类型谓词。
类型谓词(Type Predicate)
函数返回类型中的 param is Type 形式。当函数返回 true 时,TypeScript 将 param 的类型缩窄为 Type。用于将复杂的运行时类型检查逻辑封装为可复用的工具函数。注意:TypeScript 相信你的谓词函数实现是正确的,如果谓词函数写错,类型系统无法发现。
可辨识联合(Discriminated Union)
每个联合成员都有一个值为字面量类型的公共属性(discriminant/tag 字段),TypeScript 可以通过比较这个字段的值精确缩窄到某个联合成员。是处理"多种可能状态"最类型安全的模式,比 class 继承更灵活,比字段 optional 更明确。
穷举性检查(Exhaustiveness Check)
利用 never 类型实现:在 switch 的 default 分支断言剩余类型为 never——如果联合类型新增了一个成员但 switch 未处理,TypeScript 会在编译时报错。这是"让编译器检查业务逻辑完整性"的最佳实践。

类型缩窄(Type Narrowing)

什么是类型缩窄?

TypeScript 的类型缩窄是指:在代码的特定分支中,TypeScript 能根据条件判断,将一个宽泛类型(如 string | number)自动缩窄为更精确的类型。这让你可以安全地使用该分支中已确定的类型特有方法。

typeof 类型保护

function process(value: string | number | boolean) {
  if (typeof value === 'string') {
    // 此块中 value: string
    console.log(value.toUpperCase());
  } else if (typeof value === 'number') {
    // 此块中 value: number
    console.log(value.toFixed(2));
  } else {
    // 此块中 value: boolean(已排除其他可能)
    console.log(value ? '是' : '否');
  }
}

instanceof 类型保护

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

function handleError(error: unknown) {
  if (error instanceof ApiError) {
    // error: ApiError
    console.log(`HTTP ${error.statusCode}: ${error.message}`);
  } else if (error instanceof Error) {
    // error: Error
    console.log(`Error: ${error.message}`);
  } else {
    console.log('Unknown error', error);
  }
}

自定义类型谓词(Type Predicates)

interface Fish { swim: () => void; name: string; }
interface Bird { fly: () => void; name: string; }

// is 关键字:返回值类型是类型谓词
function isFish(animal: Fish | Bird): animal is Fish {
  return ('swim' in animal);
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim();  // animal: Fish
  } else {
    animal.fly();   // animal: Bird
  }
}

可辨识联合(Discriminated Union)

最强大的类型缩窄模式

// 每个联合成员有一个「标签字段」(discriminant)
type Result<T> =
  | { kind: 'success'; data: T; }
  | { kind: 'error'; code: number; message: string; }
  | { kind: 'loading'; progress: number; };

function handleResult<T>(result: Result<T>) {
  switch (result.kind) {
    case 'success':
      console.log(result.data);       // data: T
      break;
    case 'error':
      console.error(`[${result.code}] ${result.message}`);
      break;
    case 'loading':
      console.log(`加载中... ${result.progress}%`);
      break;
  }
}

in 运算符与属性缩窄

// in 运算符:检查属性是否存在
interface Dog { bark: () => void; breed: string; }
interface Cat { purr: () => void; indoor: boolean; }

function speak(animal: Dog | Cat) {
  if ('bark' in animal) {
    animal.bark();   // animal: Dog
  } else {
    animal.purr();   // animal: Cat
  }
}

// 真值缩窄:过滤 null/undefined
function printLength(s: string | null | undefined) {
  if (s) {
    // s: string(null 和 undefined 都是假值)
    console.log(s.length);
  }
}

// 注意:空字符串 "" 是假值!下面是更精确的缩窄
function printLength2(s: string | null | undefined) {
  if (s != null) {  // != null 同时排除 null 和 undefined
    console.log(s.length);  // s: string(包括空字符串)
  }
}
真值缩窄会误杀空字符串和 0

使用 if (value) 缩窄 string | null 时,空字符串 "" 也会被过滤掉,因为空字符串是假值。如果业务中空字符串和 null 应有不同处理,必须用 value !== null && value !== undefinedvalue != null(== 宽松比较)。同理,数字 0 也是假值,不能用 if (count) 来判断"count 存在"。

satisfies 运算符(TypeScript 4.9)

type Config = {
  [key: string]: string | number | boolean;
};

// 问题:as 断言丢失字面量类型
const config1 = {
  host: 'localhost',  // 推断为 string
  port: 3000,          // 推断为 number
} as Config;

// satisfies:保留字面量类型的同时验证符合 Config
const config2 = {
  host: 'localhost',  // 仍然是 'localhost' 字面量类型
  port: 3000,          // 仍然是 3000 字面量类型
} satisfies Config;

// 因为保留了具体类型,可以直接调用字符串方法
config2.host.toUpperCase();  // ✅(config1 不行,因为 string|number|boolean 没有这个方法)
类型缩窄是 TypeScript 的核心竞争力

TypeScript 的类型缩窄让你可以写出既安全又表达力强的代码。可辨识联合(带 kind/type/tag 字段的联合类型)是最推荐的模式——它让 switch/if 语句成为类型安全的穷举检查,比 class 继承更灵活,比简单的 if-else 更健壮。Redux 的 action、React 的状态机、API 响应类型都大量使用这一模式。

穷举检查(Exhaustiveness Checking)

可辨识联合配合 never 类型,可以让 TypeScript 强制检查是否所有 case 都被处理:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function assertNever(x: never): never {
  throw new Error(`未处理的形状: ${JSON.stringify(x)}`);
}

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 0.5 * shape.base * shape.height;
    default:
      // 如果 Shape 类型新增了一个成员但这里没处理,
      // assertNever 会让 TypeScript 编译报错
      return assertNever(shape);
  }
}

类型断言的正确使用

// as 断言:告诉 TypeScript "我比你更了解这个类型"
const input = document.getElementById('search') as HTMLInputElement;
input.value = 'hello';  // 可以访问 HTMLInputElement 的属性

// ! 非空断言:断言值不为 null/undefined
const canvas = document.getElementById('canvas')!;  // 断言不为 null

// 双重断言(慎用):任何类型 as unknown as 目标类型
// 用于临时绕过类型检查,正式代码中应避免
const hack = someValue as unknown as string;

// as const:将值的类型收窄为字面量类型
const STATUS = {
  ACTIVE: 'active',
  INACTIVE: 'inactive',
} as const;
// STATUS.ACTIVE 的类型是 'active'(字面量),而不是 string
type Status = typeof STATUS[keyof typeof STATUS];
// Status = 'active' | 'inactive'

控制流分析的边界情况

赋值后类型重置

TypeScript 的控制流分析会追踪每次赋值操作。一旦变量被重新赋值,之前缩窄的类型会被重置为赋值表达式的类型——即使重新赋值发生在 if 分支之后。

function reassignTest(x: string | number) {
  if (typeof x === 'string') {
    // 此处 x: string
    x = x.toUpperCase();  // ✅ 合法
    // 重新赋值后 x 的类型变为赋值表达式的类型
  }
  // 此处 x: string | number(恢复到声明类型)
}

// 闭包会捕获变量的"声明时类型",而不是"缩窄后类型"
function closureTest(x: string | null) {
  if (x !== null) {
    // 此处 x: string
    const fn = () => x.toUpperCase();  // ❌ 错误!
    // 闭包中 x 可能在 fn 调用时被重新赋值为 null
    // TypeScript 4.4+ 对闭包保守处理:闭包内类型退回到声明类型
  }
}
闭包捕获变量时类型缩窄会失效

在缩窄块内部创建闭包(箭头函数、回调函数)时,TypeScript 不会将缩窄后的类型传入闭包——因为闭包可能在稍后调用,此时变量的值可能已经改变。解决方案:在闭包外将缩窄后的值赋给一个新的 const 变量:const s = x;,然后闭包捕获 s(类型始终是 string)。

never 类型的传播

never 作为不可达代码的标志
当所有联合类型的可能性都被排除后,变量类型变为 never。never 类型可以赋值给任何类型,但没有任何值可以赋值给 never(底部类型特性)。这也是穷举检查能工作的原因:default 分支中 shape 变为 never,assertNever(x: never) 接受它。
never 在函数返回类型中
函数返回类型为 never 时,意味着函数永远不正常返回(抛出异常、无限循环等)。这也会影响调用点的控制流:在 throw 语句之后,TypeScript 知道后续代码不可达,并相应地缩窄联合类型。
条件类型中的 never 过滤
在条件类型中,never 在分布式条件类型中具有"过滤"语义:type NonNullable<T> = T extends null | undefined ? never : T。当 T 为联合类型时,never 成员会自动从结果联合中移除。
// throw 语句后的类型缩窄
function getUser(id: string | undefined): string {
  if (!id) {
    throw new Error('id is required');
    // throw 之后,id 的类型在后续代码中变为 string(never 被消除)
  }
  // 此处 id: string(undefined 已在上面 throw 掉)
  return id.toUpperCase();  // ✅ 安全
}

// never 过滤联合类型
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>;
// A = string(null 和 undefined 映射到 never,从联合中消失)

本章小结

本章核心要点