typeof 能区分原始类型和函数,是收窄 unknown 的首选手段。
typeof v 的结果 | 收窄为 | 备注 |
|---|
"string" | string | — |
"number" | number | ⚠️ NaN 也是 number |
"bigint" | bigint | — |
"boolean" | boolean | — |
"symbol" | symbol | — |
"undefined" | undefined | — |
"object" | object | null | ⚠️ null 也走这里! |
"function" | Function | — |
1
2
3
4
5
6
7
8
9
10
11
12
13
| function describe(v: unknown): string {
switch (typeof v) {
case "string": return `字符串: ${v}`;
case "number": return `数字: ${v}`;
case "bigint": return `大整数: ${v}`;
case "boolean": return `布尔: ${v}`;
case "symbol": return `符号: ${v.toString()}`;
case "undefined": return "未定义";
case "object": return v === null ? "null" : "对象"; // ⚠️ 必须再判 null
case "function": return `函数: ${v.name}`;
default: return "不可能到这里";
}
}
|
🛑 typeof null === "object" 是 JavaScript 的历史 bug,TypeScript 忠实地保留了它。所以:
1
2
3
4
5
6
| function f(v: string | null) {
if (typeof v === "object") {
return "这里能拿到 null"; // ⚠️ 编译器认为 v 可能是 null
}
return v; // 这里 v 是 string(null 被上面的分支拿走了)
}
|
typeof v === "object" 不会排除 null。判定非空对象要写:
1
| if (typeof v === "object" && v !== null) { /* 现在 v 是 object */ }
|
typeof 判不出数组、Date、类实例——它们的结果都是 "object":
| 想判断 | 别用 typeof | 用 |
|---|
| 数组 | typeof v === "array" 🛑 永远 false | Array.isArray(v) |
| Date | typeof v === "Date" 🛑 | v instanceof Date |
| 类实例 | typeof v === "MyClass" 🛑 | v instanceof MyClass |
| 普通对象 | typeof v === "object" ⚠️ 不够 | v !== null && typeof v === "object" && !Array.isArray(v) |
instanceof——收窄为某个类:
1
2
3
4
| function fmt(v: Date | string) {
if (v instanceof Date) return v.toISOString(); // v: Date
return v.toUpperCase(); // v: string
}
|
⚠️ instanceof 用原型链判断,跨 realm(如 iframe、Worker)会失效——不同 realm 的同名类原型不同。跨 realm 数据用 Object.prototype.toString.call(v) 或鸭子类型判断。
⚠️ 接口(interface)在运行时不存在,所以 x instanceof SomeInterface 不能编译。接口只能靠自定义守卫。
in——按属性存在性收窄:
1
2
3
4
5
6
7
| type A = { a: number };
type B = { b: string };
function f(v: A | B) {
if ("a" in v) return v.a; // v: A
return v.b; // v: B
}
|
in 对可选属性的行为要小心:
1
2
3
4
5
6
7
8
| type P = { a?: number; b?: string };
function g(p: P) {
if ("a" in p) {
return p.a; // 类型是 number | undefined ⚠️ 因为 a 本身可选
}
return p.b;
}
|
💡 in 判断的是「属性是否存在」,不是「值是否为 undefined」。{ a: undefined } 里 "a" in p 为 true。
真值收窄(truthiness)——最简洁,但覆盖面容易被误解:
1
2
3
4
| function f(v: string | null | undefined) {
if (v) return v.length; // 排除了 null / undefined / ""
return 0;
}
|
| 表达式 | 排除掉的假值 |
|---|
if (v) | false、0、-0、0n、""、null、undefined、NaN |
if (!v) | 反向:保留上述假值 |
⚠️ if (v) 会连空字符串和 0 一起排除。如果 0 是合法值,必须写显式比较:
1
2
3
4
5
6
7
8
9
| function bad(n: number | null) {
if (n) return n * 2; // 🛑 n = 0 时会走 else!
return 0;
}
function good(n: number | null) {
if (n !== null) return n * 2; // ✅ 只排除 null
return 0;
}
|
相等性收窄:
1
2
3
4
5
6
| function f(a: string | number, b: string | boolean) {
if (a === b) {
return a.toUpperCase(); // a 和 b 都收窄为 string(唯一的公共成员)
}
return "";
}
|
可辨识联合收窄(switch 最常用的形态)🔥:
1
2
3
4
5
6
7
8
9
10
11
12
| type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; s: number }
| { kind: "rect"; w: number; h: number };
function area(sh: Shape): number {
switch (sh.kind) {
case "circle": return Math.PI * sh.r ** 2;
case "square": return sh.s ** 2;
case "rect": return sh.w * sh.h;
}
}
|
注意这里没写 default。如果 Shape 新增了成员,函数会因「并非所有代码路径都返回值」而报错(TS2366)——这本身就是一种穷尽性保护。
配合 never 可以做得更明确:
1
2
3
4
5
6
7
8
9
10
11
12
13
| function area2(sh: Shape): number {
switch (sh.kind) {
case "circle": return Math.PI * sh.r ** 2;
case "square": return sh.s ** 2;
case "rect": return sh.w * sh.h;
default:
const _exhaustive: never = sh; // ✅ 所有分支覆盖后,sh 是 never
return _exhaustive;
}
}
// 新增成员后立刻报错:
// ❌ TS2322: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.
|
🔥 两种穷尽性写法怎么选:
| 写法 | 加新成员时的表现 | 适合 |
|---|
不写 default + 有返回值 | TS2366 函数缺少返回 | 简单函数 |
default + never 赋值 | TS2322 明确指向未处理的分支 | 复杂逻辑,推荐 |
当内置手段不够时,用类型谓词(type predicate)自己声明收窄规则。
1
2
3
4
5
6
7
8
9
10
| function isString(v: unknown): v is string {
return typeof v === "string";
}
function f(v: unknown) {
if (isString(v)) {
return v.toUpperCase(); // ✅ v 收窄为 string
}
return null;
}
|
写法要点:
| 要点 | 说明 |
|---|
返回类型写 v is T | 而不是 boolean 🔥 |
v 必须是参数名 | 不能是 obj.prop is T 这种表达式 |
参数通常是 unknown | 便于在任何地方使用 |
| 可以用于箭头函数 | const isStr = (v: unknown): v is string => ... |
1
2
3
4
5
6
7
| // ✅ 箭头函数也可以
const isNumber = (v: unknown): v is number => typeof v === "number";
// ❌ 不能对属性写谓词——语法直接不通过
function bad(o: { a: unknown }): o.a is string { return typeof o.a === "string"; }
// ~~~~~~~~~~~~~~~~~ error TS1144: '{' or ';' expected.
// (谓词位置只接受标识符,不接受属性访问)
|
组合多个守卫:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| type User = { id: number; name: string };
function isUser(v: unknown): v is User {
return (
typeof v === "object" && v !== null &&
typeof (v as User).id === "number" &&
typeof (v as User).name === "string"
);
}
// 数组守卫
function isUserArray(v: unknown): v is User[] {
return Array.isArray(v) && v.every(isUser);
}
|
🛑 守卫是可以撒谎的,编译器不会检查:
1
2
3
4
5
6
7
8
9
| function isNum(v: unknown): v is number {
return typeof v === "string"; // ✅ 编译通过,但完全错误
}
function f(v: unknown) {
if (isNum(v)) {
return v.toFixed(2); // 编译通过;v 若是字符串,运行时崩溃
}
}
|
编译器完全信任你的谓词。所以守卫里必须真的做运行时检查。这也是为什么 11 运行时校验 建议用 schema 库自动生成守卫,而不是手写。💭
断言函数(assertion function)用于「不满足就抛异常」的场景,比守卫更适合做前置条件检查。
1
2
3
4
5
6
7
8
9
10
| function assertIsString(v: unknown): asserts v is string {
if (typeof v !== "string") {
throw new Error("期望 string");
}
}
function f(v: unknown) {
assertIsString(v);
return v.toUpperCase(); // ✅ 调用之后 v 就是 string
}
|
语法对比:
| 形式 | 返回类型写法 | 调用后的效果 |
|---|
| 类型谓词 | v is T | 在 if 为真的分支里收窄 |
| 断言函数 | asserts v is T | 调用之后的代码都收窄 🔥 |
| 无条件断言 | asserts v | 断言为真值(去假值) |
1
2
3
4
5
6
7
8
9
| // 无条件的真值断言
function assert(v: unknown): asserts v {
if (!v) throw new Error("断言失败");
}
function g(s: string | null) {
assert(s);
return s.toUpperCase(); // ✅ 去掉了 null
}
|
泛型断言函数(很实用):
1
2
3
4
5
6
7
8
9
10
| function assertDefined<T>(v: T | null | undefined): asserts v is T {
if (v === null || v === undefined) {
throw new Error("值不能为空");
}
}
function h(v: string | undefined) {
assertDefined(v);
return v.toUpperCase(); // ✅ 去掉了 undefined
}
|
⚠️ 断言函数的两个限制:
- 必须有显式类型标注的声明(不能靠推断)——所以不能用箭头函数简写形式直接赋值给无标注的变量。
- 它依赖「抛异常」来收窄,因此在 try 块之外调用才有效;跨函数边界(回调内)不保证收窄。
TS 5.5 起,简单的内联过滤函数能被自动推断出类型谓词,不需要手写 x is T。
1
2
3
4
5
6
7
8
9
10
| const arr = [1, "a", null, undefined, 2];
// 5.5 之前:结果是 (string | number | null | undefined)[],null 没被去掉
// 5.5+:自动推断为 (x) => x is string | number
const filtered = arr.filter((x) => x !== null && x !== undefined);
const total = filtered.reduce<number>(
(sum, x) => sum + (typeof x === "number" ? x : 0),
0,
);
|
能推断的条件(必须足够简单):
| 条件 | 说明 |
|---|
| 函数体是单个 return 表达式 | 不能有复杂语句 |
| 参数没有显式类型标注 | 有标注则按标注走 |
| 返回布尔表达式 | 且该表达式本身能产生收窄 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // ✅ 会被推断为 x is number
const a = arr.filter((x) => typeof x === "number");
// ❌ 有显式标注 -> 结果就是 boolean,不收窄
const b = arr.filter((x: unknown) => typeof x === "number");
// ❌ 多语句函数体 -> 不推断
const c = arr.filter((x) => {
const ok = typeof x === "number";
return ok;
});
// ✅ 想要收窄就手写谓词
const d = arr.filter((x): x is number => {
const ok = typeof x === "number";
return ok;
});
|
💡 判断口径很简单:.filter() 之后类型没变窄,就手写 : x is T。这比猜「能不能推断」快得多。
核心规则:收窄信息在函数边界处会丢失,因为编译器无法确定函数什么时候被调用、调用时变量变成了什么。
1
2
3
4
5
6
7
| function bad(v: string | null) {
if (v !== null) {
// 🛑 回调可能在任意时刻执行,编译器不敢保证 v 仍非空
setTimeout(() => v.toUpperCase(), 0);
// ~ error TS18047: 'v' is possibly 'null'.
}
}
|
三种修复方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 方案 1:提前取出到 const(推荐)🔥
function fix1(v: string | null) {
if (v !== null) {
const s = v; // s 是 string,且永不再变
setTimeout(() => s.toUpperCase(), 0); // ✅
}
}
// 方案 2:用参数传入
function fix2(v: string | null) {
if (v !== null) {
const run = (s: string) => s.toUpperCase();
setTimeout(() => run(v), 0); // ✅ 实参位置已收窄
}
}
// 方案 3:非空断言(下策)
function fix3(v: string | null) {
if (v !== null) {
setTimeout(() => v!.toUpperCase(), 0); // ✅ 但绕过了检查
}
}
|
💡 方案 1 是几乎总是正确的选择。把已收窄的值存进 const,语义立刻从「这个变量可能变」变成「这个值确定是 string」,编译器和你都轻松。
TS 5.4+ 的改进:对未被重新赋值的 let / 参数,收窄现在能保留到闭包内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| function improved(v: string | null) {
let s = v;
if (s !== null) {
return () => s.toUpperCase(); // ✅ 5.4+ 合法:s 之后从未被赋值
}
return () => "";
}
function stillBad(v: string | null) {
let s = v;
if (s !== null) {
return () => {
s = null; // ⚠️ 有赋值,收窄作废
return s.toUpperCase();
// ~ error TS18047: 's' is possibly 'null'.
};
}
return () => "";
}
|
判定标准:
| 变量在收窄后… | 闭包内是否保留收窄 |
|---|
| 从未再被赋值 | ✅ 保留(5.4+) |
| 在任意位置被赋值过 | ❌ 失效 |
let 会被重新赋值,所以收窄更脆弱:
1
2
3
4
5
| function f(v: string | null | undefined) {
if (!v) return;
v = maybeGet(); // ⚠️ 重新赋值,之前的收窄作废
return v.toUpperCase(); // ❌ TS18048: 'v' is possibly 'undefined'.
}
|
别名(aliasing)也会破坏收窄:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| type State = { value: string | null };
function bad(s: State) {
if (s.value !== null) {
mutate(s); // ⚠️ 编译器不知道这个函数做了什么
return s.value.toUpperCase(); // ❌ TS18047: 's.value' is possibly 'null'.
}
return "";
}
function good(s: State) {
const v = s.value; // ✅ 先取出到局部 const
if (v !== null) {
mutate(s);
return v.toUpperCase(); // ✅ v 不受 s 变化影响
}
return "";
}
|
💡 记住这个模式:属性访问的收窄几乎总是不稳定的,先存到局部 const。
| 场景 | 症状 | 处理 |
|---|
| 复杂表达式被重复求值 | 收窄不适用于第二次求值 | 存到 const |
| 索引访问 | obj[key] 每次访问都重新判 | 存到 const |
| 解构后再判断 | 解构出的变量是独立的 | 直接判断解构后的变量 |
| 泛型参数 | 泛型 T 不收窄 | 用 T & {} 或收窄到具体类型 |
try/catch 跨块 | 收窄不跨 catch | 块内重新判断 |
| 断言函数在回调外调用 | 回调内不收窄 | 把值作参数传入 |
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 索引访问:每次 obj[key] 都是新求值
function f(obj: Record<string, string | null>, key: string) {
if (obj[key] !== null) {
return obj[key].toUpperCase(); // ❌ TS18047: 'obj[key]' is possibly 'null'.
}
return "";
}
function fixed(obj: Record<string, string | null>, key: string) {
const v = obj[key]; // ✅ 一次求值,存起来
if (v !== null) return v.toUpperCase();
return "";
}
|
1
2
3
4
5
6
7
8
9
10
11
12
| // 泛型参数不收窄
function g<T>(v: T | null) {
if (v !== null) {
return v; // 类型是 T & {} —— 不是 T ⚠️
}
return null;
}
// 需要精确类型时改用泛型约束
function h<T>(v: T | null): T | null {
return v ?? null;
}
|