类型收窄与类型守卫
类型收窄的本质
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 时,参数确实是指定类型。但编译器不验证实现与声明的一致性。如果谓词实现有误,会导致类型系统被"欺骗",产生运行时错误。因此,类型谓词应保持简单、可测试,并在关键路径上辅以运行时校验。