TypeScript 类型守卫:typeof、instanceof 与自定义谓词的选择场景

在 TypeScript 项目里,把 unknown 或 any 收窄成具体类型是每天都要做的事。最常见的翻车现场是:接口返回的 JSON 被断言成 User,运行时字段缺失,页面白屏,控制台只留下一句 Cannot read properties of undefined (reading 'name')。类型守卫(type guard)就是解决这个问题的

在 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 够用,但注意它不校验值的类型

实战中最容易踩的坑,按出现频率排序:

  1. 对 null 遗漏判断。typeof x === "object" 之后直接取属性,运行时报 Cannot read properties of null。记住 null 与对象在 typeof 下不可分。
  2. 用 as 代替守卫。const user = data as User 不产生任何检查,属于纯粹的编译期谎言。数据来自网络、localStorage、postMessage、JSON.parse 时,一律走谓词。
  3. 谓词漏检字段导致“假类型安全”。谓词体内少写一个 typeof,编译期毫无提示,问题延后到运行时。给复杂度高的谓词补单元测试,用真实响应样本和畸形样本各跑一遍。
  4. 对 interface 使用 instanceof。报 TS2693。要么改成 class,要么写谓词。很多团队为所有 DTO 加 class 就是为了拿 instanceof,但要权衡序列化后的原型丢失问题——JSON.parse 出来的永远是普通对象。
  5. 谓词带副作用。守卫必须是纯函数,只读不写,不缓存。否则调用顺序会影响结果,且难以调试。
  6. 忽略 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) 的自动摊平语义,不必显式判断。