在 TypeScript 项目里,把 unknown 或 any 收窄成具体类型是每天都要做的事。最常见的翻车现场是:接口返回的 JSON 被断言成 User,运行时字段缺失,页面白屏,控制台只留下一句 Cannot read properties of undefined (reading 'name')。类型守卫(type guard)就是解决这个问题的机制——它让编译器在你写 if 分支时自动收窄类型,同时保证运行时的检查逻辑真实存在。
TypeScript 提供了三种常用守卫:typeof、instanceof 和用户自定义谓词(user-defined type predicate,形如 x is T)。另外还有 in 操作符和字面量判别。很多人知道它们能用,但不知道什么时候该用哪个,结果写出 typeof x === 'object' 这种对 null 也返回 true 的漏洞代码,或者给一个 interface 写了 instanceof 然后编译报错。本文按“能用、为什么能用、什么时候不能用”的顺序把这几个工具讲透,并给出实际项目中的选择清单。
先把收窄(narrowing)的机制说清楚
类型守卫本质上是“控制流分析 + 运行时检查”的组合。编译器在 if (cond) 的 cond 里识别出特定的检查模式,就认为分支内变量类型可以收窄。收窄只发生在当前作用域和当前控制流中,跨函数调用不会保留——这就是为什么需要 x is T 谓词:它把收窄能力从函数内部“导出”到调用点。
看一个没有守卫的典型问题。开启 strict(尤其是 strictNullChecks 与 useUnknownInCatchVariables)后,以下代码无法通过编译:
function getLength(value: string | string[] | null): number {
// 报错:Property 'length' does not exist on type 'string | string[] | null'
return value.length;
}
编译器报的具体信息是 TS18047: 'value' is possibly 'null' 或 TS2339: Property 'length' does not exist on type 'string | string[] | null',取决于哪个问题先被检测到。要消除它,你得先证明 value 不是 null,再根据分支处理。这就是守卫的用武之地。
需要强调一点:类型守卫不会在运行时做任何额外工作。它只是告诉编译器“我这个检查已经足够证明类型”。检查写错了,编译期不会拦你,运行时照样崩。所以选守卫的核心标准是:你的检查逻辑必须和类型定义的真实结构严格对应。
typeof:只适合原始类型,且必须防 null
typeof 是 JavaScript 原生运算符,返回字符串,TypeScript 为它定义了精确的类型映射:
| 表达式 | typeof 返回值 |
能匹配的 TS 类型 |
|---|---|---|
typeof "a" |
"string" |
string |
typeof 1 |
"number" |
number(含 NaN、Infinity) |
typeof 1n |
"bigint" |
bigint |
typeof true |
"boolean" |
boolean |
typeof Symbol() |
"symbol" |
symbol |
typeof undefined |
"undefined" |
undefined |
typeof null |
"object" |
null(历史遗留 bug,无法用 typeof 区分) |
typeof {} / typeof [] |
"object" |
object,但不能再细分 |
typeof function(){} |
"function" |
函数类型 |
最实用的是原始类型联合的收窄:
function formatValue(value: string | number | boolean): string {
if (typeof value === "string") {
return value.trim().toUpperCase();
}
if (typeof value === "number") {
return Number.isFinite(value) ? value.toFixed(2) : "非有限数";
}
return value ? "是" : "否";
}
注意 typeof value === "object" 这一句在 string | string[] | null 上会同时命中数组和 null,这是最常见的错误来源:
function pickFirst(value: string | string[] | null): string {
if (typeof value === "object") {
// 陷阱:这里 value 被收窄为 string[] | null
// 编译报错 TS18047: 'value' is possibly 'null'
return value[0];
}
return value ?? "";
}
正确写法是先排除 null,或者用 Array.isArray(Array.isArray 本身在 TS 里就是守卫,返回 arg is any[]):
function pickFirst(value: string | string[] | null): string {
if (value === null) {
return "";
}
if (typeof value === "object") {
// 此处 value: string[]
return value[0] ?? "";
}
return value;
}
typeof 的边界:它无法区分对象的具体形状,无法区分 null 与普通对象,无法区分类实例与普通对象(类实例也是 "object")。所以它只适合处理原始类型联合,以及“判断某个值是否为函数/对象”这种粗粒度检查。
一个容易被忽略的点:typeof 对未声明的变量不会抛 ReferenceError(严格模式下也一样),这使它成为环境探测的惯用手法,比如 typeof window !== "undefined"。在 SSR 场景中这条经常用来隔离浏览器代码。
instanceof:只对类有效,对 interface 无能为力
instanceof 走的是原型链查找,本质是 Object.getPrototypeOf 的重复调用。TypeScript 只在右侧是构造函数(且该构造函数有实例类型)时才收窄,所以它只对 class 及内置构造函数有意义:
class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string
) {
super(message);
this.name = "ApiError";
}
}
class NetworkError extends Error {
constructor(message: string, public readonly retryable: boolean) {
super(message);
this.name = "NetworkError";
}
}
type AppError = ApiError | NetworkError;
function describe(err: AppError): string {
if (err instanceof ApiError) {
// err: ApiError,可访问 status / code
return `接口错误 ${err.status}(${err.code})`;
}
return err.retryable ? "网络错误,可重试" : "网络错误,不可重试";
}
为什么类实例能这么用?因为 class 在运行时是真实存在的构造函数,而 interface 在编译后被完全擦除,运行时没有任何“User 类型”这回事。如果你对 interface 写 value instanceof User,会得到:
TS2693: 'User' only refers to a type, but is being used as a value here.
或者右值是一个真实函数但没被声明为类时,报 TS2359: The right-hand side of an 'instanceof' expression must be of type 'any' or of a type assignable to the 'Function' interface type.。这两种报错都指向同一个结论:想要 instanceof 守卫,必须用 class 建模。
跨 iframe / 跨 realm 是 instanceof 的经典坑。浏览器里每个 iframe 有独立的全局对象,iframe.contentWindow.Array 与主窗口的 Array 不是同一个构造函数,于是 arr instanceof Array 返回 false。同理 Node 里用 vm 模块创建上下文也会出现该问题。此时应改用 Array.isArray、ArrayBuffer.isView 或鸭子类型检查。
另外,instanceof 只看原型链,可以被 Object.create 伪造,也不受 Symbol.hasInstance 之外的控制。如果某个类重写了 static [Symbol.hasInstance],行为会偏离直觉,排查时值得留意。
自定义谓词 x is T:处理 interface 和复杂 JSON 的唯一正解
当类型是 interface、结构化对象,或者数据来自 JSON.parse 这类运行时才有形状的来源时,前两种守卫都不够用。这时写一个返回布尔值的函数,把返回类型标成 value is T:
interface User {
id: number;
name: string;
email: string | null;
roles: string[];
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<unknown, unknown>;
return (
typeof candidate.id === "number" &&
Number.isInteger(candidate.id) &&
candidate.id > 0 &&
typeof candidate.name === "string" &&
candidate.name.length > 0 &&
(candidate.email === null || typeof candidate.email === "string") &&
Array.isArray(candidate.roles) &&
candidate.roles.every((r) => typeof r === "string")
);
}
function parseUser(raw: string): User {
const data: unknown = JSON.parse(raw);
if (!isUser(data)) {
throw new TypeError("响应不符合 User 结构");
}
return data;
}
几个关键细节值得逐一说明。
- 入参必须是
unknown而不是any。any会让任何属性访问都合法,谓词内部的错误检查不会被编译器发现;unknown强迫你先做类型判断,这正是守卫的初衷。 - 谓词内部需要手动断言成
Record<unknown, unknown>。TypeScript 不会自动把object当成可索引类型,不这么做会报TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'。 - 谓词的返回值必须真的对应类型声明的全部约束。编译器相信你的
is User,不会验证函数体。如果漏检了roles,后续user.roles.map(...)就会在运行时抛TypeError。这是所有守卫里唯一“编译器完全放权”的场景,也是唯一需要写测试覆盖的场景。
谓词还有一个变体叫断言函数(assertion function),形如 asserts value is T,适合“不满足就抛错”的模式,能省掉后续的 if 判断:
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new TypeError("不是合法的 User 对象");
}
}
function handle(raw: unknown): string {
assertUser(raw);
// 从这里开始 raw 的类型是 User
return `${raw.name}#${raw.id}`;
}
注意断言函数的调用点必须被显式类型标注(函数声明或箭头函数带类型注释),否则 TS 可能不把它当断言函数处理,报 TS2775: Assertions require every name in the call target to be declared with an explicit type annotation。常见于把断言函数赋给变量后调用。
还有一条实用规则:谓词只做检查,不做转换。见过不少代码在 isUser 里顺手 value.name = value.name.trim(),这会让守卫产生副作用,调用点可能重复执行、也可能在分支外被误以为无副作用而调用。需要转换就单独写 parseUser / normalizeUser,职责分离。
in 操作符与判别联合:处理多态数据的组合拳
当联合类型的成员有唯一字段时,in 是最简洁的守卫。TypeScript 会据此收窄:
interface Circle { kind: "circle"; radius: number }
interface Rect { kind: "rect"; width: number; height: number }
type Shape = Circle | Rect;
function area(shape: Shape): number {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2;
}
return shape.width * shape.height;
}
不过对于这种场景,更推荐用字面量判别字段(discriminant),也就是上面的 kind。原因有三:in 只检查属性存在性,不检查值,"radius" in shape 对 { radius: "10" } 也为真;判别字段要求值精确匹配,且编译器能做穷尽性检查;重构时改字段名会有编译错误提示,而不是悄悄退化成 unknown。配合 switch 和 never 可以做穷尽校验:
function assertNever(x: never): never {
throw new Error(`未处理的联合成员: ${JSON.stringify(x)}`);
}
function areaSafe(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
default:
return assertNever(shape);
}
}
新增一个 Triangle 成员后,assertNever(shape) 处会立刻报 TS2345: Argument of type 'Triangle' is not assignable to parameter of type 'never',把“忘了处理新分支”变成编译期错误,这是类型守卫体系里收益最高的一条实践。
把 in 和谓词组合起来也很常见。比如要为“带 data 字段的成功响应”和“带 error 字段的失败响应”建守卫,直接写两个谓词比塞进一个巨型 typeof 表达式更清晰:
interface ApiOk<T> { data: T; error?: never }
interface ApiFail { data?: never; error: { code: string; message: string } }
type ApiResult<T> = ApiOk<T> | ApiFail;
function isApiOk<T>(res: ApiResult<T>): res is ApiOk<T> {
return res.error === undefined;
}
function unwrap<T>(res: ApiResult<T>): T {
if (isApiOk(res)) {
return res.data;
}
throw new Error(`${res.error.code}: ${res.error.message}`);
}
选型清单与常见坑汇总
把上面的结论压成一张决策表,日常按顺序判断即可:
| 场景 | 首选守卫 | 理由 |
|---|---|---|
| 原始类型联合(string/number/boolean/symbol/bigint/undefined) | typeof |
编译器原生支持,最简洁 |
| 区分函数与对象 | typeof x === "function" |
唯一能识别可调用对象的方式 |
判断 null |
x === null / x == null |
typeof null 恒为 "object" |
| 数组 | Array.isArray |
本身即守卫,且跨 realm 安全 |
类实例(Error、自定义 class) |
instanceof |
原型链检查,支持继承层级 |
interface / JSON 结构 |
自定义谓词 x is T |
interface 无运行时表示,只能结构检查 |
可判别联合(多个 interface 有共同字面量字段) |
判别字段 + switch |
值精确匹配,可穷尽检查 |
| 仅需区分“有没有某个成员” | in |
够用,但注意它不校验值的类型 |
实战中最容易踩的坑,按出现频率排序:
- 对
null遗漏判断。typeof x === "object"之后直接取属性,运行时报Cannot read properties of null。记住null与对象在typeof下不可分。 - 用
as代替守卫。const user = data as User不产生任何检查,属于纯粹的编译期谎言。数据来自网络、localStorage、postMessage、JSON.parse时,一律走谓词。 - 谓词漏检字段导致“假类型安全”。谓词体内少写一个
typeof,编译期毫无提示,问题延后到运行时。给复杂度高的谓词补单元测试,用真实响应样本和畸形样本各跑一遍。 - 对
interface使用instanceof。报TS2693。要么改成class,要么写谓词。很多团队为所有 DTO 加 class 就是为了拿instanceof,但要权衡序列化后的原型丢失问题——JSON.parse出来的永远是普通对象。 - 谓词带副作用。守卫必须是纯函数,只读不写,不缓存。否则调用顺序会影响结果,且难以调试。
- 忽略
unknown入参。谓词签名写成(value: any)会让内部错误检查失效,写成(value: unknown)才能逼出真实的结构检查。
最后提醒一个编译选项层面的细节:strictNullChecks 关闭时,null 和 undefined 会自动并入所有类型,守卫的判断会变得毫无意义,所有分支都是同一类型。现代项目应保持 strict: true,这是所有收窄行为成立的前提。exactOptionalPropertyTypes 开启后,可选属性的检查会更严格,谓词里对 undefined 的处理也要相应调整——如果某个字段类型是 string | undefined 但声明为可选 field?: string,开启该选项后赋值 undefined 会被拒绝,谓词中 candidate.field === undefined 的写法要改成 "field" in candidate && candidate.field === undefined 才能与之匹配。
小结
三种守卫的分工非常清晰:typeof 管原始类型,instanceof 管类实例,自定义谓词管 interface 与任意结构化数据。判别字段和 in 是补充手段,前者胜在可穷尽检查,后者胜在写法短。选择时不要追求“统一用一种”,而要看类型在运行时有没有表示——有表示(原始值、类)就用语言内置能力,没有表示(interface)就只能手写并测试谓词。把断言函数、assertNever 这两个小工具引入项目,配合 unknown 入参和严格模式,边界数据的类型安全基本就能闭环。
FAQ
Q1:为什么不直接用 as,写守卫太啰嗦?
as 不做任何运行时检查,只是让编译器闭嘴。数据源可信(同一模块内部传参)时用 as 没问题;数据穿过系统边界(HTTP、存储、跨线程消息)时必须用守卫,否则错误会以 undefined is not a function 的形式出现在离现场很远的地方。折中方案是把谓词集中放在 guards.ts,调用点只写 if (!isUser(x)) return,成本并不高。
Q2:isUser 里的 value as Record<unknown, unknown> 算不算破坏类型安全?
在谓词内部这是标准做法。此时 value 已被 typeof value === "object" && value !== null 保证为非空对象,断言成索引类型只是给编译器一个读取属性名的入口,所有属性值仍按 unknown 处理,读取后必须再做 typeof 校验。
Q3:instanceof 对 Error 子类在某些环境失效是怎么回事?
当编译目标是 ES5 且 Error 被继承时,内置错误构造函数的历史缺陷会导致原型链断裂,new ApiError(...) instanceof ApiError 可能返回 false。把 target 设为 ES2015 及以上即可解决;若无法调整,可用 Object.setPrototypeOf(this, ApiError.prototype) 在构造函数里修复,或用 err.name === "ApiError" 做兜底判断。
Q4:谓词和 Zod、io-ts 这类运行时校验库怎么选?
谓词适合少量、结构稳定的类型,零依赖、可读性好。字段多、规则复杂(嵌套数组、递归结构、需要错误信息与默认值)时,手写谓词会迅速膨胀,此时用 Zod 更划算,因为它的 safeParse 返回判别联合,本身就能自然收窄,还附带详细的错误路径。两者可以共存:对外部 API 响应用 Zod,内部轻量判断用谓词。
Q5:为什么我的 assertUser 调用后类型没有收窄?
常见原因是断言函数通过变量间接调用,或箭头函数没写显式类型标注,触发 TS2775。确保断言函数以函数声明形式定义并直接调用;若必须赋值给变量,写成 const assertUser: (v: unknown) => asserts v is User = (v) => { ... } 这样的显式标注形式。
Q6:要判断 Promise 该用哪个?
value instanceof Promise 在跨 realm 或 thenable 场景不可靠。更稳的做法是 typeof (value as any)?.then === "function",或者直接依赖 Promise.resolve(value) 的自动摊平语义,不必显式判断。