
聊 JavaScript 类型判断就很难绕开这个经典问题typeof到底能不能区分 String、Array、Function、Object、Number 这些类型我最初学 JS 的时候也被坑过——写typeof []想判断数组结果控制台吐出来一个object还以为自己代码写错了。后来踩的坑多了才慢慢把typeof的能力边界、底层逻辑和工程里真正可靠的判断方案摸清楚。这篇就把这些内容完整拆一遍从为什么分不清到用什么才能分清都讲透也算给自己做个阶段总结。1. 一个 typeof 能答对多少分先看它的能力边界先不急着上结论直接看typeof到底能返回什么。在 ES2020 之后typeof一共可能返回 8 种字符串每种对应一批特定的值值的类型示例typeof输出未定义undefined、未声明变量undefined字符串hellostring数字42、NaN、Infinitynumber布尔true/falseboolean大整数10nbigint符号Symbol(id)symbol函数function foo(){}function任意对象{}、[]、new Date()、/x/、nullobject看到最后一行就明白了typeof处理原始值的时候确实一把好手数字、字符串、布尔、符号、大整数基本上一查一个准但到了引用类型这层除了函数会被单独拎出来返回function剩下的数组、普通对象、日期、正则、Map、Set、null在它眼里全都是object。所以标题里那个问题的答案已经很清楚了typeof无法区分 String、Array、Object 这些引用类型。它唯一能区分的对象类型只有 Function因为函数在 JS 里有可调用callable的特殊身份。至于Number、String、Boolean这些如果指的是原始值比如数字1、字符串atypeof能识别但如果指的是包装对象比如new Number(1)、new String(a)得到的照样是object。1.1 未声明的变量也不会报错typeof 的独特价值typeof还有个容易被忽略的特性对一个从未声明过的变量使用typeof不会抛出ReferenceError而是返回undefined。console.log(typeof someUndeclaredVar); // undefined不会报错这一点在工程里很有用。比如要安全地探测某个全局 API 是否存在typeof window.somePlugin ! undefined这种写法就比直接访问变量要稳得多。不过安全探测这个场景到了 ES6 模块的暂时性死区那里会有变化这个后面专门说。1.2 包装对象为什么 typeof new String(a) 是 objectString、Number、Boolean这三个构造函数除了可以当普通函数调用返回原始值还可以用new创建包装对象。const a hello; const b new String(hello); console.log(typeof a); // string console.log(typeof b); // object console.log(a b); // false原始值不等于对象这在类型判断时特别容易翻车。我曾经排查过一个诡异问题某个接口返回的签名参数字段一开始以为是普通字符串结果后面发现是有人用new String(...)包装了一次导致typeof返回object一系列字符串方法虽然还能调用但严格相等比较全部失败if (value expected)永远进不去。当时排查半天最后用Object.prototype.toString一看才发现是个包装对象。所以判断类型时心里要有一根弦typeof只能告诉你这是原始值还是对象至于这个对象是数组、日期、包装对象还是普通对象它一概不管。2. 为什么 typeof 就是分不清它们聊聊底层判定逻辑要彻底理解这个问题光记住结论没用还得知道typeof底层到底是怎么判定的。JavaScript 引擎在内部存储每个值的时候都会给它带一个类型标签type tag。typeof运算符在执行时其实就是在读取这个类型标签然后映射成对应的字符串返回。ECMAScript 规范里对typeof的定义就是一张映射表按值的内部类型Type去查表Undefined 返回undefinedString 返回stringNumber 返回numberBoolean 返回booleanSymbol 返回symbolBigInt 返回bigint而所有 Object 类型的值……统一返回object。唯一一个特例如果这个 Object 是 Callable可调用的比如普通函数、箭头函数、类就返回function。所以typeof判断的从来不是你是用什么构造函数创建的而是你底层存储的类型标签是哪一类。数组、普通对象、日期、正则它们的构造函数完全不同但类型标签全都落在Object这一档typeof自然就区分不了。有个细节值得注意null的类型标签在引擎底层是个特殊标记但第一版 JavaScript 设计者把它和对象类型的标签混在了一起所以typeof null返回的是object。这其实是个历史遗留 bug不是刻意为之。后来社区多次讨论过要不要修正成null但考虑到有大量存量代码依赖typeof null object这个行为TC39 始终没有动它——这是典型的错误一旦上线就只能永远陪着用户的例子。写代码的时候记住它是个坑就行别指望浏览器会变。2.1 关系运算符和内部槽typeof 和 instanceof 的分工既然typeof看的是类型标签那instanceof看的又是什么它俩的分工完全不同。instanceof检查的是对象和构造函数的原型链关系右侧构造函数的prototype对象是否出现在左侧对象的原型链上。const arr []; console.log(arr instanceof Array); // true console.log(arr instanceof Object); // true数组的原型链上也能找到 Object.prototype console.log(arr instanceof Date); // false这里就引出一个非常关键的教训因为数组的原型链会经过Array.prototype最后到达Object.prototype所以arr instanceof Object也是true。换句话说试图用instanceof Object去区分是不是普通对象结果会把数组、函数、日期全都算进去根本没有区分度。而Array.isArray是另一套逻辑它走的是内部槽判断不依赖原型链。ECMAScript 规范里数组对象内部有一个[[ArrayData]]相关的内部槽Array.isArray就是检查这个内部槽是否存在所以哪怕数组来自另一个iframe、另一个window只要它内部确实是数组结构Array.isArray依然返回true。这一点在跨窗口场景里是硬指标。2.2 函数是对象里的特权阶级为什么typeof function(){}能返回function而数组、日期只能返回object因为规范在映射表里对 Callable 对象单独开了一条分支。JS 里函数本身就是对象可以挂属性、可以传参但它多了一个普通对象没有的内部槽[[Call]]运行时可以执行。这个可调用特性被typeof当成了一种身份标识。这其实也给了我们一个思路typeof在 JS 里本质上只提供三种决策信息——是不是 undefined、是不是某个原始值类型、是不是可调用对象。除此之外的精细类型判断它都管不着需要另找工具。3. 真正能区分 String/Array/Function/Object/Number 的方案盘点既然typeof不管用工程里真正用来区分这些类型的方案有哪些我按可靠性从高到低排一下基本都是日常开发里高频使用的。3.1 Object.prototype.toString.call最稳的验明正身手段这是我最常用的方案没有之一。任何值只要经过Object.prototype.toString.call(value)都会返回一个格式固定的字符串[object Type]。Object.prototype.toString.call(hello); // [object String] Object.prototype.toString.call(42); // [object Number] Object.prototype.toString.call(true); // [object Boolean] Object.prototype.toString.call(function foo(){}); // [object Function] Object.prototype.toString.call({}); // [object Object] Object.prototype.toString.call([]); // [object Array] Object.prototype.toString.call(null); // [object Null] Object.prototype.toString.call(undefined); // [object Undefined] Object.prototype.toString.call(new Date()); // [object Date] Object.prototype.toString.call(/abc/); // [object RegExp]为什么非要call因为很多对象自己重写了toString方法。数组的toString会返回元素拼接的字符串普通对象默认的toString也是被覆盖过的直接调用根本拿不到类型信息。const arr [1, 2, 3]; console.log(arr.toString()); // 1,2,3不是类型标签 console.log(Object.prototype.toString.call(arr)); // [object Array]Object.prototype.toString是被刻意设计成返回对象内部类型标签的所以只要借它来执行就能绕过实例自身的toString干扰。这个方案有一个细微的坑ES6 引入了Symbol.toStringTag允许自定义Object.prototype.toString的输出。像Map、Set、Promise这些内置对象内部就定义了Symbol.toStringTag。const map new Map(); console.log(Object.prototype.toString.call(map)); // [object Map]这也意味着理论上你可以自己伪造类型标签const fake { [Symbol.toStringTag]: Array }; console.log(Object.prototype.toString.call(fake)); // [object Array]不过在正常业务场景里没有人会闲到去伪造自己的类型标签所以这个方案依然是绝大多数情况下最可靠的判断手段。真要较真这类伪类型可以通过再检查原型链来补一层防线。3.2 instanceof语义最直观边界条件要小心instanceof的语义很直观value instanceof Constructor检查Constructor.prototype是否出现在value的原型链上。但下面几个场景用起来要格外小心。第一跨window/ 跨iframe时左右两端的构造函数不是同一个。iframe里的数组它的原型链上的Array.prototype属于那个iframe自己的执行环境所以主页面里的arr instanceof Array会返回false。第二Object.create(null)创建出来的对象没有原型obj instanceof Object也是false。第三ES6 允许通过Symbol.hasInstance改写instanceof的行为遇到某些刻意定制过的类判断结果可能不符合直觉。class MyArray extends Array {} const my new MyArray(); console.log(my instanceof Array); // true因为 MyArray.prototype 的原型是 Array.prototype console.log(my instanceof Object); // true原型链上也有 Object.prototype所以instanceof更适合用来判断某个对象是不是某个类或子类的实例而不是用来判断它是什么内置类型。如果只想区分数组直接用Array.isArray更干脆。3.3 constructor 与 Array.isArray各有各的定位value.constructor指向创建该对象的构造函数在常规场景下你能拿到[].constructor Array、abc.constructor String这样的结果。所以有人喜欢用constructor.name来判断类型const value []; console.log(value.constructor.name); // Array但这套方案有两个硬伤。一是constructor属性可以被改写原型链上的constructor也可能被替换二是代码压缩混淆时函数名和类名会变短甚至消失constructor.name不一定稳定。所以它只能算个辅助手段做不了可靠的类型判断。Array.isArray前面已经说过是判断数组的官方专用 API跨窗口也稳定只要目标是只判断数组优先用它别拿Object.prototype.toString去绕。我把四种方案的适用场景整理成一张表方便选型判断目标推荐方案理由区分原始值类型typeof直接、够用判断函数typeof value function最简写法可靠判断数组Array.isArray(value)跨窗口稳定语义明确判断 nullvalue null最稳别用 typeof判断其他内置类型Date、RegExp、Map、Set 等Object.prototype.toString.call(value)覆盖面广输出结构化判断自定义类的实例value instanceof SomeClass原型链语义符合直觉判断普通对象 vs 类实例Object.getPrototypeOf(value)比较原型深拷贝等场景专用4. 实战封装一个支持全类型的判断工具函数了解了各方案的特性实操里最好把它们组合封装成一个统一入口。我自己项目里长期维护一个类型判断工具核心思路就是按需分层优先用最可靠的方案兜底。4.1 第一版覆盖基础需求function getType(value) { // null 单独处理避免被 Object.prototype.toString 标签干扰视觉 if (value null) return null; // 数组优先用 Array.isArray跨窗口也稳定 if (Array.isArray(value)) return array; // 原始值直接走 typeof简单高效 const primitive typeof value; if (primitive ! object primitive ! function) return primitive; // 剩余情况借 Object.prototype.toString 取标签 const tag Object.prototype.toString.call(value); return tag.slice(8, -1).toLowerCase(); }这段代码的输出是统一小写字符串string、number、boolean、function、array、object、date、regexp、map、set、null、undefined等等。测试几个典型值console.log(getType(hello)); // string console.log(getType(42)); // number console.log(getType([])); // array console.log(getType({})); // object console.log(getType(new Date())); // date console.log(getType(/x/)); // regexp console.log(getType(null)); // null console.log(getType(undefined)); // undefined console.log(getType(() {})); // function console.log(getType(new Number(1))); // number注意最后一行new Number(1)会被归为number。这在绝大多数业务场景里是可接受的——因为我们通常关心的是语义上是不是数字而不是这个值是不是 Number 包装对象。如果你偏偏要区分包装对象得再加一步判断typeof value object且Object.prototype.toString.call(value) [object Number]时单独返回number-object。我在做表单校验工具时遇到过一次这种需求但从整个项目看99% 的场景都不需要。4.2 第二版扩展内置类型覆盖第一版用tag.slice(8, -1).toLowerCase()处理了大多数情况但如果对类型标签的精确性要求更高可以显式做映射顺便处理一些特殊情况比如NaN、Promise、Map等function classify(value) { if (value null) return null; if (Number.isNaN(value)) return nan; if (Array.isArray(value)) return array; const tag Object.prototype.toString.call(value); switch (tag) { case [object Boolean]: return boolean; case [object Number]: return number; case [object String]: return string; case [object Function]: return function; case [object Date]: return date; case [object RegExp]: return regexp; case [object Error]: return error; case [object Map]: return map; case [object Set]: return set; case [object WeakMap]: return weakmap; case [object WeakSet]: return weakset; case [object Promise]: return promise; case [object Null]: return null; case [object Undefined]: return undefined; default: return typeof value; } }这套方案的好处是类型名完全可控。如果要给后端埋点上报数据类型建议用这种显式映射的版本因为输出的字符串是固定的后续做统计聚合不会因为大小写不一致而混乱。再补一个判断普通对象的函数。普通对象的定义是原型要么是Object.prototype要么是null比如Object.create(null)。这个判断在深拷贝、参数校验里非常常用function isPlainObject(value) { if (value null || typeof value ! object) return false; const proto Object.getPrototypeOf(value); return proto Object.prototype || proto null; }实测一下console.log(isPlainObject({})); // true console.log(isPlainObject(Object.create(null))); // true console.log(isPlainObject([])); // false console.log(isPlainObject(new Date())); // false console.log(isPlainObject(new Map())); // false这里的关键是不要用value instanceof Object因为数组、函数、日期都满足instanceof Object。原型链对比是更精准的做法。5. 我在真实项目里踩过的类型判断的坑工具函数只是手段真正头疼的永远是在业务现场踩到的坑。下面这几个坑都是我在实际开发和排查问题过程中遇到的每个都花了不少时间才定位到根因。5.1 跨 iframe / 跨 window 后 instanceOf 全线失效有一次做后台系统页面里嵌了一个iframe用来展示外部上报系统传回的表格数据。拿到iframe里那批数据后我顺手写了个校验逻辑if (data instanceof Array) { ... }。结果诡异的事情发生了——数组明明是数组instanceof Array却返回false校验逻辑直接跳过后续字段处理全乱套。排查链路是这样的先在浏览器控制台里手动执行了各种typeof、Object.prototype.toString发现Object.prototype.toString.call(data)返回的确实是[object Array]没有任何问题接着打印Array.prototype iframe.contentWindow.Array.prototype结果是false。根因其实前面已经说过了每个执行环境都有自己的全局对象和内置构造函数。iframe里创建的数组它的原型链上挂的是iframe窗口自己的Array.prototype主页面里的Array和它并不是同一个引用。instanceof走的是原型链比对所以必然失效。而Array.isArray走的是内部槽判断不依赖具体某个Array构造函数跨窗口也能正确识别。从那以后我给自己定了个规矩在可能涉及多窗口、多 iframe 的页面里判断数组一律用Array.isArray判断其他内置类型一律用Object.prototype.toString.call绝不用裸instanceof兜底。5.2 后端传来的字符串其实是包装对象另一回是排查一个登录逻辑的签名校验。前端把某个字段传给服务端做加密签名服务端校验老是不过。前端代码看起来毫无问题const value response.data.token; if (typeof value ! string) { throw new Error(token 格式异常); }这里的逻辑是先判断类型再继续按理说不可能出错。但加日志后发现response.data.token的typeof结果居然是object而且Object.prototype.toString.call(value)返回的是[object String]。也就是说网络传输层的数据经过某些序列化处理或者中间层代码做了new String(...)包装导致前端拿到的不是原始字符串而是字符串包装对象。typeof对包装对象返回object这一下就把正常流程拦住了。修法很简单判断语义上的字符串不要只信typeof用Object.prototype.toString.call(value) [object String]或者像我封装好的getType去兜底。这类问题最难的不是修复而是定位——因为代码看起来完全正常只有打出Object.prototype.toString结果时才发现真相。所以我现在写类型校验相关逻辑时都会刻意避开裸typeof判断原始值统一走封装函数。5.3 toString 被业务代码覆盖类型判断必须借 Object.prototype还有一次是排查一个列表组件渲染异常。列表项本来应该正常展示结果控制台报错说拿不到length属性。我一步步追到一处代码function getItemCount(item) { return item.toString().match(/\d/)?.[0] || 0; }问题出在业务团队有个公共脚本为了给某个对象做日志序列化直接把Array.prototype.toString重写了。于是item.toString()返回的不再是数组元素的逗号拼接字符串而是被业务脚本改写过的一串 JSON。正则匹配到的内容完全不对整个逻辑就崩了。后来排查到根因后看代码的同事还困惑这不就是调个toString吗我说关键在于你调用的item.toString是数组自己原型链上的方法不是Object.prototype上的原始版本。真正的类型信息永远只有Object.prototype.toString能给出因为它是特意为返回类型标签设计的。从那以后类型判断这块我坚持借Object.prototype.toString.call绝不用实例自己的toString方法。这不是洁癖是血泪教训。5.4 typeof 的暂时性死区在模块里它并不总是安全前面说到typeof对未声明变量很友好不会抛错。但有个例外如果你在代码块里用let/const声明了变量并且声明位置在后面那么前面提前typeof这个变量会直接抛ReferenceError。console.log(typeof foo); // ReferenceError: Cannot access foo before initialization let foo 1;原因在于let/const声明的变量存在暂时性死区变量在作用域内被注册了但尚未初始化任何访问包括typeof都会被拒绝。这个坑不算常见但一旦碰到很容易懵——尤其是你用typeof做全局 API 探测恰好某个第三方脚本在模块顶层用let声明了同名变量就会莫名其妙报错。6. 从 typeof 到一套完整判断体系工程里的其他延伸类型判断从来不只是为了回答这是什么类型它往往是一个更大功能的前置条件。下面这几个方向是把类型判断延伸出去的典型实践。6.1 深拷贝里的类型分流实现深拷贝时第一步就是做类型分流。不同类型要创造不同的容器再递归处理内部数据。基于前面封装的getType一个支持常见类型的深拷贝可以这样写function deepClone(value, seen new WeakMap()) { if (value null || typeof value ! object) return value; if (seen.has(value)) return seen.get(value); // 解决循环引用 const type getType(value); let result; if (type array) { result []; } else if (type date) { result new Date(value.getTime()); } else if (type regexp) { result new RegExp(value.source, value.flags); } else if (type map) { result new Map(); } else if (type set) { result new Set(); } else { // 普通对象走到这里函数、原始值等在上面已提前返回 result {}; } seen.set(value, result); if (type map) { value.forEach((val, key) result.set(deepClone(key, seen), deepClone(val, seen))); } else if (type set) { value.forEach((val) result.add(deepClone(val, seen))); } else { Object.keys(value).forEach((key) { result[key] deepClone(value[key], seen); }); } return result; }注意这里对Date用new Date(value.getTime())而不是直接new Date(value)虽然效果一样但显式取时间戳更清晰对RegExp要保留source和flags否则丢失匹配模式。这个函数代码量不大但是把类型判断在工程里的应用价值完全体现出来了——判断准确后面每一步才不会出错。6.2 数据格式校验与上报在写通用型组件库或者数据校验库时类型判断更是基础中的基础。比如埋点上报你需要知道用户传进来的参数到底是数字、字符串、对象还是数组才能决定序列化策略。我维护过一个内部参数校验器核心只有一条规则所有的typeof判断全部替换为getType/classify因为后端日志里最讨厌看到object这种模糊答案——它根本没法告诉你是数组还是普通对象填了等于没填。统一返回值还有个额外好处所有调用方拿到的都是一致的字符串可以做switch聚合也可以直接打日志。日志的可读性和可排查性在出问题时能救命。这也是我推荐团队里不要在业务代码里裸写typeof value object的原因——不是它不能写而是它到底是不是数组/日期/正则这个问题裸typeof永远回答不了。6.3 面试和代码评审里的回答思路这类问题其实也是面试和代码评审里的常客。面试官问typeof 能不能区分 Array 和 Object其实想看的是你对 JS 类型系统的理解深度。我通常建议这样组织思路第一层明确结论typeof对原始值和函数可靠对数组、日期、正则、普通对象这些引用类型一律返回object所以无法区分。第二层解释原因typeof读取的是值内部的类型标签而引用类型在底层共用一个 Object 签名。第三层给出替代方案数组用Array.isArray其他内置类型用Object.prototype.toString.call自定义类实例用instanceof。第四层补充边界情况包装对象、跨窗口、Symbol.toStringTag都会影响判断结果。这四层一说既展示了结论又展示了底层理解还覆盖了实操细节基本就是一份完整的类型判断方法论。代码评审时同样适用——看到有人写data instanceof Array处理跨窗口数据或者裸typeof判断对象类型都可以把这篇的思路拿来说事判断可以用但得知道它的边界在哪。类型判断这件事技术上并不难难的是养成每次判断都清楚边界的意识。typeof不是不好用它是专为区分原始值和可调用对象设计的用对地方是神器用错地方就是坑。我自己现在写代码的习惯是所有类型判断统一走getType封装遇到特殊场景再按需叠加Array.isArray和原型链检查。这个习惯帮我少踩了不少冤枉坑也值得你试试。