类型收窄

2026-09-09 00:00    #typescript  

联合类型说"这个值可能是 A 或 B",收窄(narrowing)解决的是下一个问题:在这个分支里,它到底是哪个。这是 TypeScript 里最实用、也最容易被低估的部分——类型系统真正的价值不在于声明类型,而在于沿着控制流自动把宽类型收窄成窄类型,让你在每一行都拿到最精确的类型。

为什么要收窄

联合类型上只能访问所有成员共有的成员:

 1// 收窄的边界:哪些情况下 TypeScript 收窄不了
 2
 3interface A { a: number }
 4interface B { b: number }
 5
 6// 1. 联合类型上不能直接访问非公共成员
 7function f(x: A | B) {
 8  // @ts-expect-error TS2339: 'a' 不在 A | B 的公共成员里
 9  return x.a;
10}
11
12// 收窄之后才可以
13function g(x: A | B) {
14  if ("a" in x) return x.a;   // 收窄为 A
15  return x.b;                 // 收窄为 B
16}
17
18// 2. 赋值会让已收窄的属性失效
19function h(o: { v: string | null }): number {
20  if (o.v !== null) {
21    o.v = null;               // 赋值后收窄被重置
22    // @ts-expect-error TS18047: 'o.v' is possibly 'null'
23    return o.v.length;
24  }
25  return 0;
26}
27
28// 解法:先取出到局部变量
29function h2(o: { v: string | null }) {
30  const v = o.v;
31  if (v !== null) {
32    return v.length;          // v 是 const,收窄稳定
33  }
34  return 0;
35}
36
37// 3. 类型断言绕过检查,但不做运行时验证
38function assert(x: A | B) {
39  return (x as A).a;          // 编译通过,运行时可能是 undefined
40}
41
42// 4. in 对可选属性的窄化:单独用 in 不去掉 undefined
43type OptionalFish = { swim?: () => void };
44type Bird = { fly: () => void };
45
46function optionalIn(animal: OptionalFish | Bird) {
47  if ("swim" in animal) {
48    // @ts-expect-error TS2322: swim 仍可能是 undefined
49    const probe: () => void = animal.swim;
50    return probe;
51  }
52  // @ts-expect-error TS2339: 负分支不能排除 OptionalFish
53  return animal.fly;
54}
55
56// 真值检查排除了 undefined
57function optionalInFixed(animal: OptionalFish | Bird) {
58  if ("swim" in animal && animal.swim) {
59    animal.swim();
60    return "swim";
61  }
62  return "unknown";
63}
64
65// 属性必需时,负分支可以正确排除
66type RequiredFish = { swim: () => void };
67
68function requiredIn(animal: RequiredFish | Bird) {
69  if ("swim" in animal) return "swim";
70  animal.fly();               // 收窄为 Bird
71  return "fly";
72}
73
74// 5. 索引访问:默认不检查越界,开启 noUncheckedIndexedAccess 后会
75const arr: string[] = ["a"];
76const first = arr[0];         // 默认推断为 string
77const second = arr[1];        // 同样推断为 string —— 但运行时是 undefined
78
79// h 演示的是编译期不报错的路径,运行时确实会抛错
80try {
81  h({ v: "x" });
82} catch (e) {
83  console.log("h 抛错:", (e as Error).message);
84}
85
86console.log(f({ a: 1 }), g({ b: 2 }), h2({ v: "x" }));
87console.log(assert({ a: 3 }), first, second);
88console.log(optionalInFixed({ swim: () => {} }), requiredIn({ fly: () => {} }));
1h 抛错: Cannot read properties of null (reading 'length')
21 2 1
33 a undefined

第一条错误是核心:

1error TS2339: Property 'a' does not exist on type 'A | B'.
2  Property 'a' does not exist on type 'B'.

A | B 可能是 B,而 B 没有 a。类型检查器拒绝访问任何"不保证存在"的成员。

要访问 a,必须先证明这个值是 A。证明的方式就是收窄。

typeof 收窄

最基础的手段,用于原始类型:

 1// typeof 收窄
 2function pad(value: string | number, width: number): string {
 3  if (typeof value === "string") {
 4    return value.padStart(width);   // 这里是 string
 5  }
 6  return value.toFixed(2).padStart(width);   // 这里是 number
 7}
 8
 9// typeof 能识别的结果
10function describe(x: string | number | boolean | bigint | symbol | null | undefined | object) {
11  switch (typeof x) {
12    case "string": return `string: ${x.toUpperCase()}`;
13    case "number": return `number: ${x.toFixed(1)}`;
14    case "boolean": return `boolean: ${x}`;
15    case "bigint": return `bigint: ${x + 1n}`;
16    case "symbol": return `symbol: ${x.description}`;
17    case "undefined": return "undefined";
18    case "object": return x === null ? "null" : "object";
19    case "function": return "function";
20  }
21}
22
23console.log(pad("a", 3), pad(1.5, 6));
24console.log(describe("x"), describe(1), describe(null), describe(undefined));
1  a   1.50
2string: X number: 1.0 null undefined

typeof 在 if 里对原始类型是精确的:

判断收窄为
typeof x === "string"string
typeof x === "number"number
typeof x === "boolean"boolean
typeof x === "bigint"bigint
typeof x === "symbol"symbol
typeof x === "undefined"undefined
typeof x === "function"函数类型
typeof x === "object"object | null ← 注意含 null
typeof null === "object"

这是 JavaScript 的历史遗留问题。typeof x === "object" 不能排除 null,必须额外写 x !== null。这是最常见的收窄漏洞。

真值收窄

在 if 里直接判断,会把 falsy 值全部排除:

 1// 真值收窄:过滤 null / undefined / 空字符串 / 0
 2function greet(name: string | null | undefined): string {
 3  if (!name) return "anonymous";
 4  return name.toUpperCase();   // 这里是 string
 5}
 6
 7// 可选链与空值合并
 8type Config = { debug?: boolean; level?: number };
 9const c: Config = {};
10const debug = c.debug ?? false;     // ?? 只在 null/undefined 时取默认值
11const level = c.level ?? 1;
12
13// 注意 ?? 与 || 的区别:0 和 "" 是 falsy 但不是 nullish
14const zero = 0;
15console.log(zero || 1, zero ?? 1);   // 1 0
16
17// 明确排除 null
18function assertDefined<T>(v: T | null | undefined): T {
19  if (v === null || v === undefined) throw new Error("not defined");
20  return v;
21}
22
23console.log(greet("a"), greet(null), debug, level, assertDefined(1));
11 0
2A anonymous false 1 1

if (!name) return 之后,name 被收窄为 string——因为 null、undefined、"" 都是 falsy。

?? 与 || 的区别

a ?? b 只在 a 是 null/undefined 时用 b;a || b 在 a 是任何 falsy 值时都用 b。对 0、""、false 这些合法值,|| 会错误地替换掉它们:

1const zero = 0;
2console.log(zero || 1, zero ?? 1);   // 1 0

处理可选配置项时优先用 ??。

in 收窄

判断属性是否存在,用于区分对象形状:

 1interface Fish { swim: () => void }
 2interface Bird { fly: () => void }
 3
 4function move(animal: Fish | Bird): string {
 5  if ("swim" in animal) {
 6    animal.swim();          // 收窄为 Fish
 7    return "swimming";
 8  }
 9  animal.fly();             // 收窄为 Bird
10  return "flying";
11}

in 对可选属性的窄化(TS 4.9+)有个陷阱。属性可选时,in 判断为假不能排除该类型:

1type OptionalFish = { swim?: () => void };
2type Bird = { fly: () => void };
3
4function f(animal: OptionalFish | Bird) {
5  if ("swim" in animal) {
6    const probe: () => void = animal.swim;   // 报错:(() => void) | undefined
7  }
8  return animal.fly;                          // 报错:OptionalFish 也可能没有 fly
9}

实测两条错误:

1error TS2322: Type '(() => void) | undefined' is not assignable to type '() => void'.
2  Type 'undefined' is not assignable to type '() => void'.
3error TS2339: Property 'fly' does not exist on type 'Bird | OptionalFish'.
4  Property 'fly' does not exist on type 'OptionalFish'.

原因:OptionalFish 合法地可能没有 swim(因为它是可选的),所以 “swim 不在里面” 无法排除它。修正办法是加真值检查,或者把属性改成必需:

 1// 办法一:真值检查
 2if ("swim" in animal && animal.swim) {
 3  animal.swim();      // undefined 被排除
 4}
 5
 6// 办法二:属性必需时,负分支能正确收窄
 7type RequiredFish = { swim: () => void };
 8function g(animal: RequiredFish | Bird) {
 9  if ("swim" in animal) return "swim";
10  animal.fly();       // 收窄为 Bird,通过
11}

instanceof 收窄

instanceof 检查原型链,只能用于 class:

 1// in 收窄
 2interface Fish { swim: () => void }
 3interface Bird { fly: () => void }
 4
 5function move(animal: Fish | Bird): string {
 6  if ("swim" in animal) {
 7    animal.swim();          // 收窄为 Fish
 8    return "swimming";
 9  }
10  animal.fly();             // 收窄为 Bird
11  return "flying";
12}
13
14// instanceof 收窄:只能用于 class,因为需要运行时存在的构造函数
15class ApiError extends Error {
16  constructor(public status: number, message: string) {
17    super(message);
18  }
19}
20
21function handle(e: unknown): string {
22  if (e instanceof ApiError) {
23    return `api ${e.status}: ${e.message}`;   // 收窄为 ApiError
24  }
25  if (e instanceof Error) {
26    return `error: ${e.message}`;             // 收窄为 Error
27  }
28  return String(e);                           // 仍是 unknown
29}
30
31console.log(move({ swim: () => {} }), move({ fly: () => {} }));
32console.log(handle(new ApiError(404, "not found")), handle(new Error("boom")), handle("plain"));
1swimming flying
2api 404: not found error: boom plain

为什么只能用于 class?因为 instanceof 在运行时需要右侧的构造函数真实存在。interface 编译后被擦除(见 类型擦除),所以:

1interface User { name: string }
2value instanceof User;
3// error TS2693: 'User' only refers to a type, but is being used as a value here.

在 catch 里 instanceof 是标准做法。注意 catch 的变量类型是 unknown(TS 4.4+ 默认,配合 useUnknownInCatchVariables):

1try { } catch (e) {
2  if (e instanceof ApiError) { }      // 先收窄
3  else if (e instanceof Error) { }
4  else { String(e); }                  // 仍是 unknown
5}

可辨识联合:最有价值的模式

给每个联合成员一个共同的字面量字段作为标签,然后 switch 它:

 1// 可辨识联合:共同的字面量字段作为标签
 2type Result<T> =
 3  | { ok: true; value: T }
 4  | { ok: false; error: string };
 5
 6function unwrap<T>(r: Result<T>): T {
 7  if (r.ok) {
 8    return r.value;      // 收窄为 { ok: true; value: T }
 9  }
10  throw new Error(r.error);   // 收窄为 { ok: false; error: string }
11}
12
13// 穷尽性检查:用 never 确保所有分支都被处理
14type Shape =
15  | { kind: "circle"; radius: number }
16  | { kind: "square"; side: number };
17
18function area(s: Shape): number {
19  switch (s.kind) {
20    case "circle": return Math.PI * s.radius ** 2;
21    case "square": return s.side ** 2;
22    default: {
23      // 如果新增了 Shape 成员,这里会报错
24      const exhaustive: never = s;
25      throw new Error(`unhandled: ${JSON.stringify(exhaustive)}`);
26    }
27  }
28}
29
30console.log(unwrap({ ok: true, value: 1 }));
31console.log(area({ kind: "circle", radius: 1 }), area({ kind: "square", side: 2 }));
32try { unwrap({ ok: false, error: "failed" }); } catch (e) { console.log((e as Error).message); }
11
23.141592653589793 4
3failed

这是 TypeScript 里最值得推广的模式。三个好处:

  1. 每个分支自动收窄,不用写类型断言
  2. 穷尽性检查:漏掉一个成员会编译报错
  3. 自文档化:kind 字段本身就是可读的枚举

穷尽性检查靠 never 实现:

 1function area(s: Shape): number {
 2  switch (s.kind) {
 3    case "circle": return Math.PI * s.radius ** 2;
 4    case "square": return s.side ** 2;
 5    default: {
 6      const exhaustive: never = s;   // 新增成员时这里报错
 7      throw new Error(`unhandled: ${JSON.stringify(exhaustive)}`);
 8    }
 9  }
10}

如果给 Shape 加一个 { kind: "triangle"; ... },s 在 default 里就不再是 never,赋值报错:

1error TS2322: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.

这是重构时最有价值的一道保险。

让穷尽性检查更早暴露

开启 noImplicitReturns 和 noFallthroughCasesInSwitch(都属于 strict 家族或建议手动开启),配合 never 检查,能让"忘了处理新分支"在编译期就被抓住。

自定义类型谓词

当内置手段不够用时,用 value is T 声明一个类型谓词:

 1// 自定义类型谓词:value is T
 2interface Cat { meow: () => void }
 3interface Dog { bark: () => void }
 4
 5function isCat(a: Cat | Dog): a is Cat {
 6  return "meow" in a;
 7}
 8
 9function speak(a: Cat | Dog): string {
10  if (isCat(a)) {
11    a.meow();        // 收窄为 Cat
12    return "meow";
13  }
14  a.bark();          // 收窄为 Dog
15  return "bark";
16}
17
18// 断言函数:asserts value is T
19function assertIsString(v: unknown): asserts v is string {
20  if (typeof v !== "string") throw new Error("not a string");
21}
22
23function upper(v: unknown): string {
24  assertIsString(v);
25  return v.toUpperCase();   // 断言后收窄为 string
26}
27
28// 断言函数(无类型):asserts condition
29function assert(cond: unknown, msg = "assertion failed"): asserts cond {
30  if (!cond) throw new Error(msg);
31}
32
33// 数组过滤时用类型谓词,让返回类型收窄
34const items: (string | null)[] = ["a", null, "b"];
35const nonNull = items.filter((x): x is string => x !== null);
36// 不用类型谓词的话,nonNull 会是 (string | null)[]
37
38console.log(speak({ meow: () => {} }), speak({ bark: () => {} }));
39console.log(upper("abc"), nonNull);
40try { assert(1 + 1 === 3, "nope"); } catch (e) { console.log((e as Error).message); }
1meow bark
2ABC [ 'a', 'b' ]
3nope

两种写法:

1// 类型谓词:返回 boolean,收窄为 T
2function isCat(a: Cat | Dog): a is Cat {
3  return "meow" in a;
4}
5
6// 断言函数:不返回,收窄调用点之后的类型
7function assertIsString(v: unknown): asserts v is string {
8  if (typeof v !== "string") throw new Error("not a string");
9}

断言函数(asserts)的特别之处:它在调用点之后改变类型。上例中 assertIsString(v) 之后,v 直接被当作 string,不需要 if 包裹。

类型谓词是"你向编译器承诺",不是"编译器替你验证"

function isCat(a): a is Cat { return true; } 完全合法,编译不会报错。如果谓词实现错了,编译器不会发现——它只是相信你。所以谓词内部要写真正的检查逻辑。

数组 filter 的自动谓词

Array.prototype.filter 曾经必须手写类型谓词,TS 5.5 起会自动推断:

1const items: (string | null)[] = ["a", null];
2
3// 不用手写 x is string,TS 5.5+ / 7 自动推断出 string[]
4const nonNull = items.filter((x) => x !== null);

实测确认(TS 7.0.2):

1const items: (string | null)[] = ["a", null];
2const out = items.filter((x) => x !== null);
3const check: string[] = out;      // 通过,说明 out 被推断为 string[]

条件更复杂时推断不出来,仍需手写谓词:

1const nonNull = items.filter((x): x is string => x !== null);

收窄的边界

有些情况收窄确实帮不上忙,需要知道边界在哪:

 1// 收窄的边界:哪些情况下 TypeScript 收窄不了
 2
 3interface A { a: number }
 4interface B { b: number }
 5
 6// 1. 联合类型上不能直接访问非公共成员
 7function f(x: A | B) {
 8  // @ts-expect-error TS2339: 'a' 不在 A | B 的公共成员里
 9  return x.a;
10}
11
12// 收窄之后才可以
13function g(x: A | B) {
14  if ("a" in x) return x.a;   // 收窄为 A
15  return x.b;                 // 收窄为 B
16}
17
18// 2. 赋值会让已收窄的属性失效
19function h(o: { v: string | null }): number {
20  if (o.v !== null) {
21    o.v = null;               // 赋值后收窄被重置
22    // @ts-expect-error TS18047: 'o.v' is possibly 'null'
23    return o.v.length;
24  }
25  return 0;
26}
27
28// 解法:先取出到局部变量
29function h2(o: { v: string | null }) {
30  const v = o.v;
31  if (v !== null) {
32    return v.length;          // v 是 const,收窄稳定
33  }
34  return 0;
35}
36
37// 3. 类型断言绕过检查,但不做运行时验证
38function assert(x: A | B) {
39  return (x as A).a;          // 编译通过,运行时可能是 undefined
40}
41
42// 4. in 对可选属性的窄化:单独用 in 不去掉 undefined
43type OptionalFish = { swim?: () => void };
44type Bird = { fly: () => void };
45
46function optionalIn(animal: OptionalFish | Bird) {
47  if ("swim" in animal) {
48    // @ts-expect-error TS2322: swim 仍可能是 undefined
49    const probe: () => void = animal.swim;
50    return probe;
51  }
52  // @ts-expect-error TS2339: 负分支不能排除 OptionalFish
53  return animal.fly;
54}
55
56// 真值检查排除了 undefined
57function optionalInFixed(animal: OptionalFish | Bird) {
58  if ("swim" in animal && animal.swim) {
59    animal.swim();
60    return "swim";
61  }
62  return "unknown";
63}
64
65// 属性必需时,负分支可以正确排除
66type RequiredFish = { swim: () => void };
67
68function requiredIn(animal: RequiredFish | Bird) {
69  if ("swim" in animal) return "swim";
70  animal.fly();               // 收窄为 Bird
71  return "fly";
72}
73
74// 5. 索引访问:默认不检查越界,开启 noUncheckedIndexedAccess 后会
75const arr: string[] = ["a"];
76const first = arr[0];         // 默认推断为 string
77const second = arr[1];        // 同样推断为 string —— 但运行时是 undefined
78
79// h 演示的是编译期不报错的路径,运行时确实会抛错
80try {
81  h({ v: "x" });
82} catch (e) {
83  console.log("h 抛错:", (e as Error).message);
84}
85
86console.log(f({ a: 1 }), g({ b: 2 }), h2({ v: "x" }));
87console.log(assert({ a: 3 }), first, second);
88console.log(optionalInFixed({ swim: () => {} }), requiredIn({ fly: () => {} }));

赋值会重置收窄

1function h(o: { v: string | null }) {
2  if (o.v !== null) {
3    o.v = null;               // 赋值后收窄被重置
4    // @ts-expect-error TS18047: 'o.v' is possibly 'null'
5    return o.v.length;
6  }
7  return 0;
8}

注意上面这个函数编译通过(因为 @ts-expect-error 抑制了),但运行时真的会抛错:

1h 抛错: Cannot read properties of null (reading 'length')

这正说明收窄是编译期概念:它只是告诉类型检查器"在这一行,我认为它是 string",不产生任何运行时保护。

解法是取出到 const 局部变量,收窄就稳定了:

1const v = o.v;
2if (v !== null) {
3  return v.length;          // v 是 const,不会被重新赋值
4}

类型断言绕过检查

1function assert(x: A | B) {
2  return (x as A).a;          // 编译通过,运行时可能是 undefined
3}

as 不做运行时验证,见 类型擦除。

索引访问默认不检查越界

1const arr: string[] = ["a"];
2const second = arr[1];        // 推断为 string —— 但运行时是 undefined

实测开启 noUncheckedIndexedAccess 后:

1error TS2322: Type 'string | undefined' is not assignable to type 'string'.

这个选项属于"更严格但不属于 strict“的类别,新项目建议开启。

常见错误速查

以下消息经实际编译验证(TS 7.0.2):

错误码消息触发场景
TS2339Property 'a' does not exist on type 'A | B'.联合上访问非公共成员
TS18047'o.v' is possibly 'null'.收窄后被赋值重置
TS18046'x' is of type 'unknown'.未收窄就操作 unknown
TS2367This comparison appears to be unintentional because the types '1' and '2' have no overlap.比较两个不可能相等的字面量类型

小结

手段适用注意
typeof原始类型typeof null === "object" 陷阱
真值判断排除 falsy|| 会误伤 0/"",用 ??
in区分对象形状属性名是字符串
instanceofclass 实例不能用于 interface
可辨识联合多态对象最有价值,配 never 做穷尽性检查
类型谓词自定义逻辑编译器信任你,谓词要写对
asserts调用点后收窄需要真的抛异常

下一篇:泛型。