联合类型说"这个值可能是 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 里最值得推广的模式。三个好处:
- 每个分支自动收窄,不用写类型断言
- 穷尽性检查:漏掉一个成员会编译报错
- 自文档化:
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):
| 错误码 | 消息 | 触发场景 |
|---|---|---|
| TS2339 | Property 'a' does not exist on type 'A | B'. | 联合上访问非公共成员 |
| TS18047 | 'o.v' is possibly 'null'. | 收窄后被赋值重置 |
| TS18046 | 'x' is of type 'unknown'. | 未收窄就操作 unknown |
| TS2367 | This comparison appears to be unintentional because the types '1' and '2' have no overlap. | 比较两个不可能相等的字面量类型 |
小结
| 手段 | 适用 | 注意 |
|---|---|---|
typeof | 原始类型 | typeof null === "object" 陷阱 |
| 真值判断 | 排除 falsy | || 会误伤 0/"",用 ?? |
in | 区分对象形状 | 属性名是字符串 |
instanceof | class 实例 | 不能用于 interface |
| 可辨识联合 | 多态对象 | 最有价值,配 never 做穷尽性检查 |
| 类型谓词 | 自定义逻辑 | 编译器信任你,谓词要写对 |
asserts | 调用点后收窄 | 需要真的抛异常 |
下一篇:泛型。