打开任何一个前端项目,只要代码量到一定程度,里面藏着一堆手写类型判断基本是逃不掉的。最常见的就是 typeof xxx === 'object' 就当成普通对象处理,或者 instanceof Array 判定数组。这类代码在本地跑得好好的,一上生产环境,某些接口返回了 null,或者从另一个 iframe 里拿了个数组过来,整个判断链就崩了。所以这些年我越来越倾向于在项目里维护一套独立的工具函数,专门做类型检测与断言。这套东西早期就是几个 isXxx 函数,后来慢慢补齐了断言、空值语义、TypeScript 类型守卫,才变成一个能稳定复用、也能拿出来讲的完整模块。
这篇就想把这套封装的完整思路讲透:为什么不能用原始方法直接判断、Object.prototype.toString 为什么是公认的标尺、断言函数和普通判断函数到底差在哪、TypeScript 下怎么写才能让类型检查也跟上,以及封装过程中我实际踩过的坑。适合正在整理自己工具库的同学,也适合被某个“看着是对象其实不是对象”的问题坑过、想一次性解决的人。
1. typeof 与 instanceof 的边界,以及为什么需要自己做一层
1.1 typeof 的四个“经典误判”
先说 typeof。它速度很快、语法简单,所以很多人第一步就选它。问题在于它返回的结果粒度太粗,业务代码里根本不够用。
javascript复制typeof 42; // 'number'
typeof 'hello'; // 'string'
typeof function(){}; // 'function'
typeof undefined; // 'undefined'
typeof { foo: 1 }; // 'object'
typeof null; // 'object' ← 历史遗留 bug,不是刻意设计
typeof []; // 'object' ← 完全没区分数组
typeof new Date(); // 'object' ← 日期也是 object
typeof /regex/; // 'object' ← 正则也是 object
typeof new Map(); // 'object'
这四个“经典误判”里,最坑的是 typeof null === 'object'。这不是什么设计意图,纯粹是早期实现里 null 的内部标记是 0,正好和对象标记撞了,后来为了兼容就一直没改。其次就是数组、正则、日期这些内置对象,typeof 统一给一个 object,等于告诉你有值,但不告诉是什么。
还有一个容易忽略的:typeof NaN === 'number'。严格说这不是误判,NaN 确实是 number 类型。但业务里判断一个数字的时候,通常不希望拿到 NaN 还继续算下去。所以单靠 typeof 判断数字,等于半个判断,后续还得再查 Number.isNaN。
1.2 instanceof 的两个硬伤
instanceof 是另一条常见路线。它的原理是沿原型链查找,看构造函数的 prototype 是否出现在目标对象的原型链上。
javascript复制[] instanceof Array; // true
({}) instanceof Object; // true
new Date() instanceof Date; // true
但它有两个很硬的缺陷,在实际项目里会直接导致线上 bug。
第一个是跨全局执行环境失效。浏览器里存在多个 window,比如一个 <iframe>。iframe 里的数组是 iframe 自己的 Array 构造出来的,它的原型链上挂着的是 iframe 里的 Array.prototype,和主窗口的 Array.prototype 不是同一个对象。这时候:
javascript复制const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeArray = iframe.contentWindow.Array.of(1, 2, 3);
iframeArray instanceof Array; // false ← 明明是个数组
Array.isArray(iframeArray); // true
也就是说,只要你项目里接入了第三方 iframe、webview、electron 渲染进程之类场景,instanceof 就可能误判。
第二个是原型链可以被人为改动。Array.prototype.constructor 可以被覆盖,或者你在某些兼容性代码里重写过原型链,instanceof 的结果就和预期不一致。虽然大多数项目不会真的去篡改内置原型,但只要有人这么干过,instanceof 就不是一个可靠的约定。
1.3 Object.prototype.toString 才是真正可依赖的“标尺”
所有纠结到最后,业内基本都收敛到同一个方案:
javascript复制Object.prototype.toString.call(value);
这个调用返回一个形如 '[object TypeName]' 的字符串。它不依赖原型链,也不依赖全局变量,而是读取对象内部的 [[Class]](或现代引擎里的 Symbol.toStringTag)信息,所以跨 iframe、跨 window 基本都能给出稳定结果。
javascript复制Object.prototype.toString.call(42); // '[object Number]'
Object.prototype.toString.call('a'); // '[object String]'
Object.prototype.toString.call(null); // '[object Null]'
Object.prototype.toString.call(undefined); // '[object Undefined]'
Object.prototype.toString.call([]); // '[object Array]'
Object.prototype.toString.call({}); // '[object Object]'
Object.prototype.toString.call(/re/); // '[object RegExp]'
Object.prototype.toString.call(new Map()); // '[object Map]'
Object.prototype.toString.call(new Date()); // '[object Date]'
这就等于给了我们一把统一的尺子。只要把 [object ] 去掉、再转成小写,就得到一个稳定的类型名,用它来做后续所有 isXxx 判断的地基。这也是我最终决定自己封装一整套的核心原因——原生方法不是不能用,而是组合起来问题太多,与其每个文件里各写各的,不如集中收口到一套工具函数里,把边界问题一次解决干净。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 Object.prototype.toString 搭一套基础检测函数族
2.1 先封装一个底层 toType 方法
所有 isXxx 判断的最底层,我会先做一个 toType。它不直接对外暴露太多复杂逻辑,主要就是把 Object.prototype.toString 的返回结果处理成一个统一的小写字符串。
typescript复制// src/utils/type.ts
const toString = Object.prototype.toString;
/**
* 返回值的标准化类型名,如 'number'、'array'、'null'、'map'
*/
export function toType(value: unknown): string {
return toString.call(value).slice(8, -1).toLowerCase();
}
slice(8, -1) 的意思是去掉前 8 个字符 '[object ',去掉最后一个 ']',然后统一转小写。这样 [object Array] 就变成 array,干净整齐,后续判断直接比较字符串即可。
为什么要先包一层?因为直接在你的业务代码里写 Object.prototype.toString.call(xxx) === '[object Array]',一方面每个地方都写一遍太长,容易抄错;另一方面这个字符串格式太内部,维护性差。抽成 toType 之后,所有判断只需要一次计算,后面都是字符串比较,性能和可读性都能兼顾。
2.2 主要 isXxx 函数的实现
有了 toType 打底,基础判断函数的实现就简单了。我一般这样写:
typescript复制export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}
export function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
export function isUndefined(value: unknown): value is undefined {
return value === undefined;
}
export function isNull(value: unknown): value is null {
return value === null;
}
export function isFunction(value: unknown): value is Function {
return typeof value === 'function';
}
export function isArray(value: unknown): value is any[] {
return Array.isArray(value);
}
export function isObject(value: unknown): value is Record<string, unknown> {
return toType(value) === 'object';
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (toType(value) !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
几个关键点解释一下。
isString 我直接用 typeof,没有走 toType。因为 typeof 在判断基础类型上又快又准,不需要额外调用 toString。isNumber 额外排除了 NaN,因为大多数业务场景里“NaN 不当作有效数字”。isArray 用原生 Array.isArray,这是唯一一个连跨 iframe 都能正确处理的原生数组判断方法,比自己用 toType 更可靠。
isObject 和 isPlainObject 是我刻意做区分的两兄弟。isObject 只要 toType(value) === 'object' 就成立,所以数组、日期、正则都不算 object,这符合大多数人的直觉。isPlainObject 则更进一步,要求“纯对象”,具体说就是它的原型链要么是 Object.prototype,要么是 null(也就是 Object.create(null) 创建的对象),凡是原型链上有其他东西的——比如某个 class 的实例——都不算 plain object。
这两者通过一张表就能看得很清楚:
| 值 | isObject | isPlainObject |
|---|---|---|
{} |
true | true |
Object.create(null) |
true | true |
new Date() |
false | false |
[] |
false | false |
class Foo {} 的实例 |
false | false |
new String('a') |
false | false |
2.3 特殊值处理:NaN、Infinity、-0
基础函数搭完之后,还有一批容易被忽略的特殊值要单独处理。
NaN 刚才说过了,在 isNumber 里被排除,但如果业务里明确“我要判断一个变量是不是 number 且允许 NaN”,就得单独提供 isNumberLike 或者 isRawNumber 之类的函数。我的习惯是只保留一个 isNumber,默认排除 NaN,但另外提供 isFiniteNumber:
typescript复制export function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
Infinity 和 -Infinity、-0 这一类,属于 number 类型,但不一定是你想要的“常规数字”。比如做数学计算时,Infinity 传进去可能直接产出错误结果。所以我的工具库里 isNumber 不做全量排除,isFiniteNumber 才做严格版。
-0 这个比较冷门。Object.is(-0, 0) 是 false,但 -0 === 0 是 true。如果某些场景下需要区分这两个,需要用 Object.is,普通类型判断不用管它,知道有这种边界存在就行。
还有 0.1 + 0.2 不等于 0.3 这类浮点精度问题,和类型检测无关,但通常在封装数字判断时会被一并提出来。我的建议是工具函数里不掺浮点修正逻辑,保持“只判断类型,不处理数值计算”,职责越单一越好。
2.4 Symbol 和 BigInt 也不该漏
ES6 之后还有两个类型容易被遗漏:Symbol 和 BigInt。
typescript复制export function isSymbol(value: unknown): value is symbol {
return typeof value === 'symbol';
}
export function isBigInt(value: unknown): value is bigint {
return typeof value === 'bigint';
}
这两个类型用 typeof 直接判断是最靠谱的,toType 只能作为兜底。尤其 Symbol,在封装一些配置读取、扩展字段时很常用,不判断直接当普通值处理会出很多隐性问题。
3. 断言函数:从“判断”到“拦截”
3.1 不抛错的判断,等于只做了一半
当你写下一行:
javascript复制if (isString(name)) {
// 这里才放心用 string 的方法
}
这个模式本身没问题,但它是“防御式”的。真正线上出 bug 的时候,反而是那些“忘了加 if”的地方。比如一个函数接收 config 参数,你预期它是个对象,但调用方传了个字符串。如果你只做了 if (isObject(config)) 然后 continue,很可能就把错误数据静默吞掉了,直到下游某个难以定位的状态才爆出来。
断言函数要解决的就是这个:把类型检查从“是否返回 true/false”升级成“是否符合预期,不符合直接抛错”。说白了,它让类型错误在一个可控、可诊断的位置立刻暴露,而不是层叠到后面无法追踪。
3.2 断言函数的基本形态
我最常用的断言函数写法如下:
typescript复制export function assertString(value: unknown, name: string = 'value'): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`${name} 必须是 string,实际得到 ${toType(value)}`);
}
}
调用方式很简单:
typescript复制function greet(name: unknown) {
assertString(name, 'name');
// 到这里 TS 已经知道 name 是 string
return `Hello, ${name.toUpperCase()}`;
}
greet('tony'); // OK
greet(123); // TypeError: name 必须是 string,实际得到 number
这里有两个点值得说。
第一是 asserts value is string 这个 TypeScript 断言语法。它不是普通类型谓词 value is string,而是一种更激进的表达式:如果函数没有抛出异常,那么从这个函数返回之后,value 的类型就被收窄成 string。这比手动 if + else throw 写起来干净很多,数据流也更顺畅。
第二是错误信息。我把参数名 name 设计成第二个参数,这样错误信息里可以直接带上出错的是哪个变量。实际项目里一个函数有七八个参数的时候,报错只说 value 必须是 string 完全不够定位,必须点名“第几个参数、参数名叫什么、实际类型是什么”。
typescript复制export function assertNumber(value: unknown, name: string = 'value'): asserts value is number {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeError(`${name} 必须是有效数字,实际得到 ${toType(value)}`);
}
}
export function assertArray<T = unknown>(value: unknown, name: string = 'value'): asserts value is T[] {
if (!Array.isArray(value)) {
throw new TypeError(`${name} 必须是数组,实际得到 ${toType(value)}`);
}
}
export function assertObject(value: unknown, name: string = 'value'): asserts value is Record<string, unknown> {
if (toType(value) !== 'object') {
throw new TypeError(`${name} 必须是对象,实际得到 ${toType(value)}`);
}
}
3.3 错误类型怎么选,信息怎么组织
断言函数抛错,我用 TypeError,不用默认的 Error。因为 TypeError 语义更准确——“这个值的类型不对”,也方便调用方用 instanceof TypeError 做统一捕获。
错误信息的组织我总结了一个固定套路:
- 基础信息:哪个参数、期望什么类型、实际是什么类型。
- 期望类型用中文或英文描述,但全项目保持统一,别一个文件中文一个文件英文。
- 对于数组,尽量补上实际长度;对于对象,可以补上实际取到的 key 列表。这些额外信息在排查时非常值钱。
typescript复制export function assertNonEmptyArray<T>(value: unknown, name: string = 'value'): asserts value is T[] {
if (!Array.isArray(value)) {
throw new TypeError(`${name} 必须是数组,实际得到 ${toType(value)}`);
}
if (value.length === 0) {
throw new Error(`${name} 必须是长度大于 0 的数组,实际长度为 0`);
}
}
这里第二个抛错用的就是普通 Error,因为它不是类型错了,而是值不满足业务条件。区分“类型不对”和“值不对”,会让排查问题的人舒服很多。
3.4 别把断言函数当成普通判断函数的参数化变体
有人会想:既然断言就是判断失败后抛错,那我完全可以写一个通用封装:
typescript复制export function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
这个我也写过,但它有两个问题。第一是 assert(condition, message) 丢失了“参数类型”信息,报错内容完全依赖外部传,类型不统一。第二是它和 isXxx 组合时,代码会变成:
typescript复制assert(isString(name), 'name 必须是 string');
这等价于没有用 asserts value is string。因为 assert 只能把 isString(name) 这个表达式的类型断言为真,它不能让 TypeScript 自动从 name 是 unknown 推断成 name 是 string。所以针对特定类型的断言函数(assertString、assertNumber)才有类型收窄的魔力,一个通用 assert 做不了这件事。这也是我建议“论函数不厌其烦,每个类型写一个专用断言”的原因。
4. 空值语义与可空断言:实际业务里最不好拿捏的一对
4.1 isNil 与 isPresent,先把空值定好标准
讲到类型检测,绕不开 null 和 undefined。它们经常成对出现,我干脆封装两个语义明确的工具函数:
typescript复制export function isNil(value: unknown): value is null | undefined {
return value === null || value === undefined;
}
export function isPresent<T>(value: T | null | undefined): value is T {
return !isNil(value);
}
这两个函数的关键不在实现,而在统一团队里对“空”的口径。有的接口返回 null 表示“没有”,有的字段缺省就是 undefined,很多 bug 就是这两者混用导致的。定义 isNil 之后,只要判断“空”的地方都走它,就能把 null 和 undefined 当同一个语义处理,避免每个模块自己切来切去。
isPresent 是它的反义。注意这里用了泛型收窄:
typescript复制function getName(name: string | null | undefined) {
if (isPresent(name)) {
// 这个分支里 name 是 string
return name.length;
}
return 0;
}
这个写法比 if (name !== null && name !== undefined) 直观多了,也避免了 if (name) 这种把空字符串、0、false 一并吞掉的隐患。
4.2 可空断言:只说“非空”可能不够,要能区分“完全不能接受空”和“允许空但有值才处理”
有了 isNil 之后,断言函数也要跟着扩展。我的工具库里会为每个主要断言加一个 OrNil 版本:
typescript复制export function assertStringOrNil(value: unknown, name: string = 'value'): asserts value is string | null | undefined {
if (isNil(value)) {
return; // null / undefined 是允许的
}
if (typeof value !== 'string') {
throw new TypeError(`${name} 必须是 string、null 或 undefined,实际得到 ${toType(value)}`);
}
}
为什么需要这种变体?因为在真实业务里,一个从外部传入的字段往往是可选的。比如某个用户信息接口,nickname 可能不存在,也可能为空字符串。处理的时候,“没有传”和“传了个数字”是完全不同的错误级别:前者正常降级就行,后者说明接口或者调用方出了问题。断言函数严格版负责后者,OrNil 负责前者,分工很明确。
这个区分在实际项目中可以避免大量“为了容忍空值导致真正错误也被吞掉”的问题。如果你只写一个 assertString,那可选字段还得先 if (isNil(x)) return 再调断言,每处都要写一遍样板代码;有了 OrNil 版本,一行搞定。
4.3 isEmpty 的“空”到底怎么定义
除了 null/undefined,“空值”更广的语义还包括空数组、空对象、空字符串、空 Map。我在工具库里的 isEmpty 是业务导向的,不是类型导向的,但也要说清楚它的“空”指什么:
typescript复制export function isEmpty(value: unknown): boolean {
if (isNil(value)) return true;
if (typeof value === 'string' || Array.isArray(value)) {
return value.length === 0;
}
if (value instanceof Map || value instanceof Set) {
return value.size === 0;
}
if (toType(value) === 'object') {
return Object.keys(value as object).length === 0;
}
return false;
}
这里最容易引发争议的是对象判断。Object.keys({}).length === 0 只能判断没有可枚举的自有属性,但对象如果是 class 实例,或者带 Symbol 属性,这个判断就不完整。所以 isEmpty 的定位我明确说明:它只负责“业务里常见的空”,不是严格意义上的“空对象”。如果你想判断一个对象是不是除了原型链之外完全没有属性,那要写更深层级的逻辑,而不是依赖这个薄封装。
5. TypeScript 类型守卫:让断言函数帮编译器“理解数据”
5.1 类型谓词 value is T 的用法
TypeScript 里,普通函数返回 boolean 并不会自动把钱收窄类型。比如:
typescript复制function isString(value: unknown) {
return typeof value === 'string';
}
function demo(value: string | number) {
if (isString(value)) {
value.toUpperCase(); // TS 报错:number 没有 toUpperCase
}
}
为了让编译器明白“isString 返回 true 时 value 就是 string”,需要使用类型谓词:
typescript复制function isString(value: unknown): value is string {
return typeof value === 'string';
}
它告诉 TypeScript:当函数返回 true 时,value 的类型可以被收窄为 string。我在前面所有 isXxx 函数里都写了这种返回类型,这让它们不只是运行时工具,同时也成为编译期的“类型守卫”。
5.2 asserts value is T 和类型谓词的区别
类型谓词和断言语法有一个关键差别:谓词判断返回 true/false,调用方需要写 if;asserts 则完全不返回,只表示“如果没抛错,就说明 value 是某种类型”。
typescript复制function assertNumber(value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new TypeError('不是 number');
}
}
function demo(value: string | number) {
assertNumber(value);
// 到这里 value 自动被理解为 number
value.toFixed(2);
}
使用场景怎么选?
- 如果调用方希望“根据判断结果走不同分支”,用
value is T。 - 如果调用方希望“不满足就直接抛错,后面继续按正确类型处理”,用
asserts value is T。
我一般建议:判断函数全部用 value is T,断言函数全部用 asserts value is T。这样命名上 isXxx 和 assertXxx 本身就带出了它们的返回方式,使用方只看名字就能判断该怎么用。
5.3 守卫与断言混合使用的实战例子
一个典型场景:解析外部 JSON 数据。数据可能是任意结构,解析前全是 unknown。这时需要层层收窄,我的做法是先用 assertObject 断言根类型,再逐步取属性、调 isString、isArray。
typescript复制interface UserInfo {
id: number;
name: string;
tags: string[];
}
export function parseUser(data: unknown): UserInfo {
assertObject(data, 'data');
assertNumber(data.id, 'data.id');
assertString(data.name, 'data.name');
assertArray(data.tags, 'data.tags');
// 到这里 TS 已经知道 data 是 Record<string, unknown>,
// 但 data.tags 仍然是 unknown,需要 map 前再收窄一次
const tags = data.tags.filter((tag): tag is string => typeof tag === 'string');
return {
id: data.id,
name: data.name,
tags,
};
}
这种写法把“校验 + 收窄 + 抛出可定位错误”一次性完成,比手写一堆 if (typeof xxx !== 'number') throw 清爽很多。而且一旦某个字段类型不对,抛错信息会准确到 data.id 这一级,线上排查时间可以压缩到很短。
5.4 泛型与断言配合:封装一个 typed assertArray
断言函数也可以支持泛型,让类型系统更精确。比如我常见的是 assertArray:
typescript复制export function assertArray<T = unknown>(value: unknown, name: string = 'value'): asserts value is T[] {
if (!Array.isArray(value)) {
throw new TypeError(`${name} 必须是数组,实际得到 ${toType(value)}`);
}
}
调用时如果明确知道数组元素类型,可以写出更精准的断言:
typescript复制function processIds(raw: unknown) {
assertArray<number>(raw, 'raw');
// 到这里 raw 被理解为 number[]
const sum = raw.reduce((acc, item) => acc + item, 0);
return sum;
}
如果没有指定泛型,raw 会被推断成 unknown[],后面用起来还得继续收窄元素类型。所以在泛型方面,我的原则是:能在调用处确定元素类型就尽量指定,工具函数只负责“这是一个数组”这件事,元素类型交给调用方。
6. 落地封装时的设计细节,和我踩过的那些坑
6.1 模块导出结构:别用 default 一把梭
建议工具库的导出采用具名导出,避免 default 导出整个对象:
typescript复制export { toType, isString, isNumber, isObject, isPlainObject, isArray, isNil, isPresent, isEmpty } from './type';
export { assertString, assertNumber, assertObject, assertArray, assertStringOrNil } from './assert';
这样做有几个原因。第一是 tree shaking 友好,打包时只留下实际用到的函数。第二是 IDE 自动导入时,具名导出能够精确定位来源。第三是语义更清晰——不写 import TypeUtils from 'xxx',而是 import { isString } from 'xxx',一眼就知道拿到的是什么。
我建议工具函数模块内部分成两层:type.ts 放所有 isXxx 判断函数,assert.ts 放所有 assertXxx 断言函数。两者之间很自然:断言函数内部调用判断函数或直接写同样的判断逻辑。分文件也能避免一个文件一两百行,维护心情都会好一些。
6.2 坑一:toString.call 的结果可能被 Symbol.toStringTag 篡改
Object.prototype.toString 并不是绝对“诚实”的。ES6 之后,一个对象可以通过定义 Symbol.toStringTag 属性来自定义 toString 返回的类型名。
javascript复制class MyArray {
get [Symbol.toStringTag]() {
return 'Array';
}
}
Object.prototype.toString.call(new MyArray());
// '[object Array]'
也就是说,toType(value) === 'array' 并不一定代表它真是个数组,有可能是某个人为了骗过 toString 故意搞了 Symbol.toStringTag。
这个坑在封装时特别要留意。我的处理方式是:
- 凡是内置类型的判断,优先用原生 API(
Array.isArray比toType === 'array'可靠)。 toType作为兜底,但不要假设它返回的字符串一定等于真实类型。- 对于自定义类实例,如果要用 toString 判断,必须先确认那个类没有覆盖
Symbol.toStringTag。
6.3 坑二:跨 iframe 虽然过了,但 window 的表现不一致
Object.prototype.toString.call(window) 在大多数现代浏览器返回 '[object Window]',但某些老的 WebKit 内核会返回 '[object DOMWindow]' 或者别的变体。如果你封装一个 isWindow 之类的函数,不要只比对一种字符串。同理,globalThis 在不同宿主里返回也可能不同。
更通用的做法是优先用特性检测而不是 toString。比如想判断是不是 Window,可以尝试 value instanceof Window,但跨 iframe 也可能失效。说实话,多数项目根本不需要判 window,遇到再单独实现,不要为了“通用”在工具库里塞入一堆宿主相关判断。
6.4 坑三:arguments 对象和宿主对象
Object.prototype.toString.call(arguments) 返回 '[object Arguments]'。这本身没问题,但如果你没有单独写 isArguments,直接落进 isObject 判断里,它是不会通过的,因为 toType(arguments) === 'arguments',不是 'object'。
传参类函数在兼容老代码时很容易碰到这种情况。早期项目还常见 function() { const args = arguments; } 这种模式,如果你用前面封装的 isArray 去判断它,结果就是 false。为了避免误判,我特别加了一个 isArrayLike:
typescript复制export function isArrayLike(value: unknown): boolean {
return !isNil(value) && typeof value !== 'function' && typeof (value as any).length === 'number';
}
这个实现判断“像数组”,也就是有数值型 length 属性。它不是严格类型判断,但在处理 DOM 集合、arguments、类数组对象的场景里非常实用。
6.5 坑四:性能不是主要问题,但别在热路径里滥用
Object.prototype.toString.call 的性能比 typeof 慢一个量级,但现代 JS 引擎优化得其实不差。真正要担心的是在循环里反复调用。比如一个 data 数组有 10000 项,每项都要 isPlainObject,就会产生 10000 次 toString 调用。这种情况下可以把判断提取到循环外,或者用更粗粒度的 typeof 先过滤一次。
typescript复制if (typeof value === 'object' && value !== null) {
// 这里才走 toString 等更精确的判断
return isPlainObject(value);
}
也就是“慢路径长痛不如短痛”:先用廉价判断过滤掉大多数明显不符合的情况,剩下少数再用重量级方法确认。这种分层策略在封装工具函数时很实用。
6.6 测试清单:这些边界必须覆盖
封装完不是直接上线,我会把这批边界用例加进测试里,它们是踩坑经验的浓缩:
| 测试值 | 期望 |
|---|---|
null |
isNull true,isObject false,isNil true |
undefined |
isNil true,isUndefined true |
[] |
isArray true,isObject false(注意这里 isObject 只认纯对象) |
[1, 2, 3] |
isArray true,且 assertArray<number> 后元素收窄为 number |
Object.create(null) |
isPlainObject true |
带 Symbol.toStringTag 的类实例 |
不误判为内置类型 |
arguments 对象 |
isArrayLike true,isArray false |
| iframe 里创建的数组 | isArray true |
NaN |
isNumber false,isFiniteNumber false |
new String('a') |
isString false(包装对象不是原始字符串),toType 为 'string' |
每一行测试背后都对应过一个真实场景。把这些边界固定下来,工具函数往后重构才敢放心动。
最后说一点我个人的体会。封一套类型检测与断言函数,代码量其实不大,复杂度也不在高深的语言特性上,而在于你是否把边界想全了、把语义定清楚了。每多一个函数,都要问一次:它和其他函数的重叠在哪里?命名是否一眼能看出用途?配不配 TypeScript 的 is 或 asserts?这些想清楚之后,这套工具就不再是“几个判断函数”那么简单,而是项目里默认的“数据入口校验层”。后面如果要用,建议从小范围开始,先在一个模块里试跑,确认错误信息、命名风格都合适了再全量铺开。别一上来就造十几个函数往全项目撒,那样造出来的往往不是工具,而是另一堆包着壳的遗留代码。
