CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
TypeScript 核心/课时/类型收窄
RawBlame
课时目录12 节

TypeScript 核心

  1. 01TypeScript 是什么
  2. 02基本类型
  3. 03函数类型
  4. 04接口与类型别名
  5. 05联合与交叉类型
  6. 06类型收窄
  7. 07泛型
  8. 08工具类型
  9. 09模块与 tsconfig
  10. 10React 组件 typing
  11. 11异步与 API 类型
  12. 12综合练习与 Next.js

类型收窄

typeof、in、 discriminated union、断言谨慎使用。

30 分钟

类型收窄

这一课你将能做到

  1. 用 typeof、instanceof、in 操作符把联合类型缩到具体分支
  2. 手写类型守卫函数 is Xxx,让自定义逻辑也能触发类型收窄
  3. 用 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 变成了编译期错误。

讲清楚要点

  1. typeof 收窄对 primitive 有效:string、number、boolean 等。null 的 typeof 是 "object",typeof null === "object" 是 JS 历史 bug,但 TS 仍然把它收窄成 object | null,需要额外处理。
  2. instanceof 只认 class:接口和类型别名在编译后不存在,不能 instanceof。判断接口形态用 in 或自定义类型守卫。
  3. in 收窄的条件:"prop" in obj,要求 obj 不是 primitive。属性名必须是字符串字面量或 string/number/symbol,太宽会收窄失败。
  4. 类型守卫的本质是编译器契约:你告诉 TS「如果这个函数返回 true,参数就是某类型」。契约靠你维护——如果守卫逻辑写错了(比如实际判断了 x !== null 却声称 x is string),编译器信了你的鬼话,运行时就会崩。
  5. 断言函数适合「先验条件」:参数进来前就必须满足某形状,不满足不往下走。断言函数比守卫更简洁,但风险也更大——抛了异常调用方要兜底。
  6. 穷尽检查是联合类型的安全网: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

卡点急救

  1. typeof x === "object" 分支里 x.foo 报错:x 被收窄为 object | null,先判 x !== null 再访问属性。
  2. 自定义守卫不触发收窄:守卫函数签名没写 is Type。检查写法是 function isX(v: SomeType): v is TargetType。
  3. 断言函数在 strictNullChecks 下报错:断言函数接收的参数类型必须是确定的。不确定时先用普通守卫收窄再断言。
  4. 穷举检查突然报错:联合类型加了新成员,某处的 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[],所以调用后参数会被收窄为数组类型。

自测三问

  1. typeof null === "object" 这个历史 bug 在 TS 类型收窄里会产生什么影响?怎么规避?
  2. 写一个自定义类型守卫函数的完整签名,说明每个部分的作用。
  3. 为什么 const _: never = x 放在 switch 的 default 分支,就能让新增联合成员时编译报错?

小结一句话

类型收窄是 TS 编译器的核心能力:通过 typeof、instanceof、in、等值判断或自定义守卫,让联合类型的每个分支都能被精确处理;再用 never 兜底,确保没有漏网之鱼。

延伸

  • Handbook: Narrowing
  • 下一课:泛型——类型参数 <T>、泛型函数/接口/类、约束、默认参数、条件类型初探。
加载本节练习…
← 上一课联合与交叉类型下一课 →泛型