CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
类型收窄
这一课你将能做到
用 typeof、instanceof、in 操作符把联合类型缩到具体分支
手写类型守卫函数 is Xxx,让自定义逻辑也能触发类型收窄
用 never 做穷尽检查,确保联合类型的每个分支都被处理到
为什么要学
前一课学了联合类型 A | B,但联合类型真正好用之前,你得先解决一个问题:编译器怎么知道此刻它到底是 A 还是 B?
没有收窄,联合类型就是一块未切割的木板——你知道里面有东西,但没法精准取到。类型收窄就是刻刀:写一段条件代码,让 TypeScript 在条件成立的分支里「缩小」变量的类型范围,从而开放出这个分支才有的字段和方法。
这是 TS 最体现编译器智能的地方,也是面试爱考的「类型收窄机制」。不会收窄,联合类型形同虚设;会收窄,你就能写出类型安全的 if/else、switch、try/catch,每个分支里编译器都像你的结对程序员。
先建立图像
变量 value: string | number
┌──────────────┐
│ string? │ ── typeof value === "string" ──▶ 收窄为 string
│ number? │ ── typeof value === "number" ──▶ 收窄为 number
└──────────────┘
收窄后,每个分支里 value 只有对应类型的能力
核心逻辑:向编译器证明 变量在当前代码块里属于哪个类型,证明方式就是运行时检查(typeof、instanceof、属性探测)。
跟着做
新建 narrowing-demo.ts,逐步验证。
步骤 1:typeof 收窄
function printValue(value: string | number): string {
if (typeof value === "string") {
// 这个分支里 value 被收窄为 string
return value.toUpperCase();
}
// 到这里 value 只能是 number
return value.toFixed(2);
}
console.log(printValue("hello")); // HELLO
console.log(printValue(3.14159)); // 3.14
注意:JS 的 typeof 只有八种返回值:"string"、"number"、"boolean"、"undefined"、"bigint"、"symbol"、"object"、"function"。不要用 typeof x === "array"——数组 typeof 是 "object"。
步骤 2:instanceof 收窄
class Cat {
meow() {
return "喵";
}
}
class Dog {
bark() {
return "汪";
}
}
function speak(animal: Cat | Dog): string {
if (animal instanceof Cat) {
return animal.meow(); // 收窄为 Cat
}
return animal.bark(); // 收窄为 Dog
}
console.log(speak(new Cat())); // 喵
console.log(speak(new Dog())); // 汪
instanceof 只能用于类(class)实例。接口没有运行时存在,不能用 instanceof 判断。
步骤 3:in 操作符——检查属性是否存在
interface Bird {
fly(): void;
wings: number;
}
interface Fish {
swim(): void;
fins: number;
}
function move(animal: Bird | Fish): string {
if ("fly" in animal) {
return `飞吧,${animal.wings} 只翅膀`;
}
return `游吧,${animal.fins} 个鳍`;
}
console.log(move({ fly() {}, wings: 2 })); // 飞吧,2 只翅膀
console.log(move({ swim() {}, fins: 3 })); // 游吧,3 个鳍
in 收窄最适合「字面量对象 + 接口」的场景——没有 class 时,检查某个属性是否存在是最自然的判断方式。
步骤 4:类型守卫函数(自定义 is) interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Rectangle | Circle;
// 类型守卫函数:返回 boolean,参数名后跟 `is 类型`
function isRectangle(shape: Shape): shape is Rectangle {
return shape.kind === "rectangle";
}
function area(shape: Shape): number {
if (isRectangle(shape)) {
// 这里 shape 自动收窄为 Rectangle
return shape.width * shape.height;
}
// 否则为 Circle
return Math.PI * shape.radius ** 2;
}
console.log(area({ kind: "rectangle", width: 4, height: 5 })); // 20
console.log(area({ kind: "circle", radius: 2 })); // 12.566...
关键点:shape is Rectangle 是类型谓词(type predicate) 。有了这个签名,调用 isRectangle 的 if 分支里,TS 就知道 shape 是 Rectangle。
步骤 5:断言函数——检查失败直接抛错 // 断言函数:条件不成立时抛异常,从而收窄后续代码
function assertIsRectangle(shape: Shape): asserts shape is Rectangle {
if (shape.kind !== "rectangle") {
throw new Error("预期是矩形,但实际不是");
}
}
function describe(shape: Shape): string {
assertIsRectangle(shape); // 如果过了这行,shape 就被收窄为 Rectangle
return `矩形,宽 ${shape.width} 高 ${shape.height}`;
}
console.log(describe({ kind: "rectangle", width: 3, height: 4 }));
// 矩形,宽 3 高 4
// 传 Circle 会抛异常
// describe({ kind: "circle", radius: 2 });
断言函数是类型守卫的激进版:它不放回 false,而是失败就抛错。如果你确定某个路径下必定是某类型,用它最省力。
步骤 6:等值收窄与字面量收窄 type Status = "idle" | "loading" | "done";
function nextStatus(current: Status): Status {
if (current === "idle") {
return "loading";
} else if (current === "loading") {
return "done";
}
// 到这里 current 只能是 "done"
return current;
}
console.log(nextStatus("idle")); // loading
console.log(nextStatus("loading")); // done
console.log(nextStatus("done")); // done
用 === 比对字面量,也是一种收窄。判别联合(上一课)就是靠这个机制工作的。
步骤 7:never 穷尽检查 如果联合类型增加了新成员,但你忘了处理,TS 怎么提醒你?用 never:
interface Square {
kind: "square";
size: number;
}
interface Rect {
kind: "rect";
w: number;
h: number;
}
interface Circle2 {
kind: "circle";
r: number;
}
type Shape2 = Square | Rect | Circle2;
function area2(shape: Shape2): number {
switch (shape.kind) {
case "square":
return shape.size ** 2;
case "rect":
return shape.w * shape.h;
case "circle":
return Math.PI * shape.r ** 2;
default:
// shape 在这里应该是 never(所有可能分支都处理完了)
const _exhaustiveCheck: never = shape;
throw new Error(`未知形状:${_exhaustiveCheck}`);
}
}
console.log(area2({ kind: "square", size: 3 })); // 9
console.log(area2({ kind: "circle", r: 1 })); // 3.14...
测试一下:如果在 Shape2 里加一个 Triangle,但 switch 没补 case "triangle",default 里 shape 就不再是 never,编译器会报错:Type 'Triangle' is not assignable to type 'never'。这就把「漏处理新分支」从运行时 bug 变成了编译期错误。
讲清楚要点
typeof 收窄对 primitive 有效 :string、number、boolean 等。null 的 typeof 是 "object",typeof null === "object" 是 JS 历史 bug,但 TS 仍然把它收窄成 object | null,需要额外处理。
instanceof 只认 class :接口和类型别名在编译后不存在,不能 instanceof。判断接口形态用 in 或自定义类型守卫。
in 收窄的条件 :"prop" in obj,要求 obj 不是 primitive。属性名必须是字符串字面量或 string/number/symbol,太宽会收窄失败。
类型守卫的本质是编译器契约 :你告诉 TS「如果这个函数返回 true,参数就是某类型」。契约靠你维护——如果守卫逻辑写错了(比如实际判断了 x !== null 却声称 x is string),编译器信了你的鬼话,运行时就会崩。
断言函数适合「先验条件」 :参数进来前就必须满足某形状,不满足不往下走。断言函数比守卫更简洁,但风险也更大——抛了异常调用方要兜底。
穷尽检查是联合类型的安全网 :switch 每个分支处理完,剩下的用 never 接住。日后扩展联合类型,漏补分支编译器当场红线提醒。
你可能会懵的地方
typeof [] 是 "object",那怎么收窄数组?
用 Array.isArray(value),它是内置的类型守卫,返回 value is unknown[](实际推断得更精确)。
typeof null === "object" 怎么办?
如果你写 if (typeof x === "object"),x 会被收窄为 object | null。需要再加 if (x !== null) 才能完全收窄到 object。
类型守卫和类型断言 as 有什么区别?
守卫是运行时检查 + 编译器契约;断言 as 是绕过编译器检查,「我保证它是 X,你信我」。断言没有运行时保障,守卫有。能用守卫就别用断言。
断言函数抛了异常,返回值收窄还生效吗?
生效。断言函数通过了,后续代码路径里类型就被收窄。如果断言失败抛错,代码不会继续执行,所以不存在「断言通过了但类型没收窄」的情况。
为什么 default 分支里 shape 是 never?
因为前面 case 把所有可能的成员都处理完了,TS 在分析完所有分支后确定剩余类型为空集,也就是 never。如果漏了分支,剩余类型就不是 never,赋值给 never 的变量就会报错。
错法对照 错法 问题 正解 typeof arr === "array"JS 里数组 typeof 是 "object" 用 Array.isArray(arr) if (obj.kind) { ... }kind 可能是空字符串或 0,误判用 === 精确比对字面量:if (obj.kind === "xxx") 接口用 instanceof 接口编译后不存在 用 in 或自定义类型守卫 类型守卫逻辑写错但编译通过 守卫是开发者给编译器的承诺,逻辑错误会运行时炸 仔细测试守卫逻辑,尽量用可验证的判定条件 switch 不写 default / 不用 never 兜底 新增联合成员时无人提醒,容易漏处理 switch 补 exhaustive default:const _: never = x
卡点急救
typeof x === "object" 分支里 x.foo 报错 :x 被收窄为 object | null,先判 x !== null 再访问属性。
自定义守卫不触发收窄 :守卫函数签名没写 is Type。检查写法是 function isX(v: SomeType): v is TargetType。
断言函数在 strictNullChecks 下报错 :断言函数接收的参数类型必须是确定的。不确定时先用普通守卫收窄再断言。
穷举检查突然报错 :联合类型加了新成员,某处的 switch/if 没覆盖。按编译器红线定位到漏处理的位置补分支。
面试常问
TS 类型收窄有哪些手段?
typeof(primitive)、instanceof(class 实例)、in(属性探测)、等值比对(字面量联合)、自定义类型守卫 is、断言函数 asserts、赋值收窄、控制流分析(条件语句后的分支自动收窄)。
类型守卫和类型断言的区别?
类型守卫是运行时检查 + 类型收窄,有运行时保障;as 断言是编译期强制转换,没有运行时检查,用错了会在运行时报 undefined is not a function。优先守卫,断言是最后手段。
什么是穷尽检查(exhaustiveness checking)?
在 switch 或 if/else 末尾用 never 接住剩余可能性。如果联合类型新增成员但没覆盖,剩余类型不再是 never,编译器会报错。这是用类型系统做「漏处理」防护的典型手段。
Array.isArray 为什么是类型守卫?
TS 把 Array.isArray 的签名定义为 isArray(arg: any): arg is any[],所以调用后参数会被收窄为数组类型。
自测三问
typeof null === "object" 这个历史 bug 在 TS 类型收窄里会产生什么影响?怎么规避?
写一个自定义类型守卫函数的完整签名,说明每个部分的作用。
为什么 const _: never = x 放在 switch 的 default 分支,就能让新增联合成员时编译报错?
小结一句话 类型收窄是 TS 编译器的核心能力:通过 typeof、instanceof、in、等值判断或自定义守卫,让联合类型的每个分支都能被精确处理;再用 never 兜底,确保没有漏网之鱼。
延伸