# 数据类型

Source: https://codewiki.com/zh/javascript/data-types/

> - **what**: JavaScript 是动态类型语言：变量绑定没有固定类型，值有类型。语言值分为七种基本值和对象。
> - **trap**: 运算符和条件会按各自规则强制转换；`"false"` 是真值，`Number("")` 是 `0`，而 `+` 既可能相加也可能拼接。
> - **fix**: 在输入边界按字段语义显式解析并验证，默认值优先区分 `null`／`undefined` 与其他假值，比较时明确选择相等算法。

## 是什么，为什么存在

JavaScript 采用动态类型（dynamic typing）：类型属于运行时的值，而不是变量名。一个 `let` 绑定可以先指向数字，之后再指向字符串；这不会改变原值的类型，而是让绑定指向另一个值。动态类型让同一套语法处理来自表单、JSON 和库调用的不同数据，但边界上的类型假设必须由代码写清楚。

语言中的值分为对象和七种基本值（primitive value）：`undefined`、`null`、Boolean、Number、BigInt、String 与 Symbol。基本值本身不可变；对字符串做替换或转大小写会得到新字符串。对象可以拥有可变属性，数组和函数也属于对象，不是额外的语言类型。

赋值与传参传递的都是值。复制基本值会得到那个基本值；复制对象值会得到指向同一对象的引用，因此两个绑定可以观察同一批属性变化。把这个区别说成“基本值在栈上、对象在堆上”并不可靠，那是实现策略，不是 ECMAScript 语义。

`const` 约束绑定，不能让它改指向另一个值，却不会冻结对象。`const order = { count: 1 }` 仍允许修改 `order.count`。需要只读对象契约时，应通过 API 设计、复制或冻结表达，而不能从 `const` 推断深层不可变。

这些类型在三个地方最容易影响业务结果：检查外部输入、选择运算符语义，以及判断两个值是否相等。问题通常不是“JavaScript 没有类型”，而是代码把输入类型、转换规则或缺失值策略留成了隐含假设。

## 工作原理

### 七种基本类型与对象

`undefined` 通常表示尚未提供值，例如缺失属性或没有显式返回值的函数。`null` 是程序显式使用的空值。两者是否含义不同由接口契约决定，语言只规定它们是两个不同的基本值。

Boolean 只有 `true` 和 `false`。String 是 UTF-16 码元序列，Symbol 是具有唯一标识的基本值，也可作为属性键。Number 使用 IEEE 754 双精度浮点格式，同时表示普通整数、小数、`Infinity` 和 `NaN`。

BigInt 表示任意精度整数，字面量以 `n` 结尾。它与 Number 是不同的数值类型；大多数算术运算不能混用两者，因为运行时不能擅自决定应保留 BigInt 的精度，还是采用 Number 的范围与小数语义。比较或转换前，应先确定领域需要哪一种数值表示。

Object 是其余语言值的类型。普通对象、数组、日期、正则表达式、Map、Set 和函数都在这一类中。不同对象即使属性相同也有不同标识；多个引用只有在指向同一个对象时才通过严格相等比较。

### `typeof` 是粗粒度检查

`typeof` 返回固定字符串，适合区分基本类型和检测可调用值，但它不是通用的对象分类器。下面的表格给出常见结果与更精确的检查方式。

| 值 | `typeof` 结果 | 需要更精确检查时 |
| --- | --- | --- |
| `undefined` | `"undefined"` | 通常无需补充 |
| `null` | `"object"` | `value === null` |
| Boolean | `"boolean"` | 通常无需补充 |
| Number | `"number"` | `Number.isFinite()`、`Number.isNaN()` |
| BigInt | `"bigint"` | 通常无需补充 |
| String | `"string"` | 通常无需补充 |
| Symbol | `"symbol"` | 通常无需补充 |
| 函数 | `"function"` | 必要时再检查具体协议 |
| 数组及其他对象 | `"object"` | `Array.isArray()` 或领域检查 |

`typeof null === "object"` 是保留至今的兼容行为。数组也返回 `"object"`，所以数组分支必须用 `Array.isArray(value)`。判断“非空对象”至少需要 `value !== null && typeof value === "object"`，之后仍要校验所需属性，而不是把所有对象视为同一种记录。

### 转换取决于上下文

类型强制转换（type coercion）是操作按需要自动转换值。条件与 `!` 使用布尔转换；多数算术运算使用数值转换；模板插值和 `String()` 使用字符串转换。显式调用 `Boolean()`、`Number()` 或 `String()` 使用相关规则，但“显式”不表示结果一定符合业务语义。

布尔转换只有一组固定假值：`false`、`0`、`-0`、`0n`、`""`、`null`、`undefined` 和 `NaN`。其余语言值都是真值，包括 `"0"`、`"false"`、空数组和空对象。`if (value)` 适合询问“它的真值是什么”，不适合代替“字段是否存在”或“数组是否为空”。

`&&` 和 `||` 会检查操作数的真值，但返回某个原操作数，不会固定返回 Boolean。`left && right` 在 `left` 为假值时返回 `left`，否则返回 `right`；`left || right` 在 `left` 为真值时返回 `left`，否则返回 `right`。`??` 只在左侧为 `null` 或 `undefined` 时取右侧，因此能保留有效的 `0`、`false` 和空字符串。

数值转换也有需要在边界上防守的结果。`Number("")` 和 `Number("   ")` 都是 `0`，`Number(null)` 也是 `0`，无效数字文本则得到 `NaN`。`parseInt()` 解析前缀，所以 `parseInt("12px", 10)` 得到 `12`；它适合明确允许后缀的语法解析，不适合验证“整个字段必须是十进制整数”。

### 运算符决定强制转换路径

减法、乘法、除法和取余先把操作数转换为数值。加法先把对象转换为基本值；若任一结果是 String，就执行拼接，否则执行数值加法。因此，`1 + 2 + "3"` 是 `"33"`，而 `"1" + 2 + 3` 是 `"123"`，因为表达式从左向右结合。

关系比较同样不能概括成“都转数字”。两个操作数转换为基本值后，如果两者都是字符串，就按字符串的 UTF-16 码元顺序比较；否则走数值比较。于是 `"10" < "2"` 为 `true`，而 `Number("10") < Number("2")` 为 `false`。

宽松相等 `==` 使用抽象相等比较，可能转换操作数，但不是简单地“把两边转成同一类型”。例如 `null == undefined` 为 `true`，两者与其他值的宽松相等比较通常为 `false`；Boolean 会先转换为 Number；对象与基本值比较时会先进行对象到基本值转换。除非接口有意使用 `value == null` 同时匹配两个空值，业务代码通常更适合使用严格相等并在边界显式转换。

### 相等不是一种算法

严格相等 `===` 不做类型转换。不同类型直接不相等；对象按标识比较；`NaN` 与自身不相等；`+0` 与 `-0` 相等。它是大多数分支判断的默认选择，但不能解决内容相同对象的结构比较。

`Object.is()` 使用 SameValue 语义：`Object.is(NaN, NaN)` 为 `true`，`Object.is(+0, -0)` 为 `false`。`Array.prototype.includes()`、Map 键和 Set 成员使用SameValueZero（同值零相等），它把 `NaN` 视为等于自身，同时把两个零视为相等。选择集合或查找 API 时，这些边界差异会直接决定命中结果。

## 示例

下面四个示例依次检查类型、解析外部配置、观察运算符转换，并比较相等算法。所有输出都由本地 Node 24 执行对应文件得到。

### 检查语言类型

这个探针先用 `typeof` 做粗分类，再为 `null` 和数组补上专用判断。它没有尝试为每个内置对象制造一个“精确类型名”，因为业务代码通常需要验证能力或字段，而不是依赖可伪造的标签。

<!-- quick -->

```javascript
// file: inspect_values.js
const samples = [
  undefined,
  null,
  false,
  42,
  42n,
  "42",
  Symbol("id"),
  [42],
  { value: 42 },
  () => 42,
];

for (const value of samples) {
  const kind = value === null
    ? "null"
    : Array.isArray(value)
      ? "array"
      : typeof value;
  console.log(kind);
}
```

```text
undefined
null
boolean
number
bigint
string
symbol
array
object
function
```


<!-- /quick -->

这里的 `kind` 是应用层分类，不是 ECMAScript 新增的类型系统。它有意回答“是否为 null、数组或其他 `typeof` 类别”，若函数只接受有限结构，还应继续检查自有属性及其值的类型。

### 在边界解析配置

环境变量、URL 参数和表单字段通常以字符串进入程序。这个解析器只接受契约列出的拼写，拒绝部分数字，并用 `??` 保留空标签。

```javascript
// file: parse_options.js
function parseBoolean(value, field) {
  if (value === true || value === "true") return true;
  if (value === false || value === "false") return false;
  throw new TypeError(`${field} must be true or false`);
}

function parseRetries(value) {
  const text = String(value);
  if (!/^(0|[1-9]\d*)$/.test(text)) {
    throw new TypeError("retries must be a non-negative integer");
  }
  const retries = Number(text);
  if (!Number.isSafeInteger(retries)) {
    throw new RangeError("retries is outside the safe integer range");
  }
  return retries;
}

function parseOptions(raw) {
  return {
    retries: parseRetries(raw.retries),
    verbose: parseBoolean(raw.verbose, "verbose"),
    label: raw.label ?? "worker",
  };
}

console.log(parseOptions({ retries: "0", verbose: "false", label: "" }));
console.log(parseOptions({ retries: 3, verbose: true }));
```

```text
{ retries: 0, verbose: false, label: '' }
{ retries: 3, verbose: true, label: 'worker' }
```

`Number.isSafeInteger()` 确保结果是安全整数（safe integer），而不是已经舍入的 Number。解析策略是接口的一部分：如果输入契约允许前后空白、正号或本地化数字，应在正则与测试中明确加入，而不是依赖转换函数猜测。

### 观察上下文转换

同一个字符串在不同运算符中会走不同路径。`JSON.stringify()` 让空字符串和字符串中的空格在输出里可见，避免终端显示掩盖结果。

```javascript
// file: coercion_contexts.js
const inputs = ["5", "", "false", 0, null];

for (const value of inputs) {
  console.log(JSON.stringify({
    input: value,
    boolean: Boolean(value),
    number: Number(value),
    defaultWithOr: value || "fallback",
    defaultWithNullish: value ?? "fallback",
  }));
}

console.log("5" + 2);
console.log("5" - 2);
console.log(1 + 2 + "3");
```

```text
{"input":"5","boolean":true,"number":5,"defaultWithOr":"5","defaultWithNullish":"5"}
{"input":"","boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":""}
{"input":"false","boolean":true,"number":null,"defaultWithOr":"false","defaultWithNullish":"false"}
{"input":0,"boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":0}
{"input":null,"boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":"fallback"}
52
3
33
```

第三行的 `number` 显示为 `null`，不是因为 `Number("false")` 返回 `null`，而是因为它返回 `NaN`，且 JSON 数组或对象序列化会把非有限 Number 写成 `null`。调试转换时，应直接检查 `Number.isNaN()`，不要把序列化文本误当作内存中的原值。

### 对比相等与对象标识

这个例子把严格相等、SameValue 和 SameValueZero 放在一起。最后两行表明对象内容相同不代表对象标识相同。

```javascript
// file: compare_values.js
console.log(NaN === NaN);
console.log(Object.is(NaN, NaN));
console.log([NaN].includes(NaN));

console.log(+0 === -0);
console.log(Object.is(+0, -0));
console.log(new Set([+0, -0]).size);

const first = { id: 7 };
const alias = first;
const copy = { id: 7 };

console.log(first === alias);
console.log(first === copy);
```

```text
false
true
true
true
false
1
true
false
```

若业务需要按 `id` 判断记录是否代表同一实体，应直接比较经过验证的 `id`。通用深比较牵涉循环引用、原型、访问器、Symbol 键和领域规则，不应把 `JSON.stringify(a) === JSON.stringify(b)` 当成可靠替代品。

## 陷阱

### 把 `typeof` 当成完整分类器

> **陷阱:** `typeof value === "object"` 同时匹配 `null`、数组和许多内置对象。直接读取属性会让 `null` 分支抛错，把数组当记录处理则可能静默产生错误结果。

**修复方法：** 先排除 `null`，数组用 `Array.isArray()`。随后按接口验证所需字段、属性所有权与值类型，不要从一个粗粒度标签推断完整结构。

### 用 `Boolean()` 解析布尔文本

> **陷阱:** `Boolean("false")` 和 `Boolean("0")` 都是 `true`，因为任何非空字符串都是真值。AI 生成的配置解析器常把环境变量直接交给 `Boolean()`，从而打开本应关闭的功能开关。

**修复方法：** 为边界定义允许的文本，例如只接受 `"true"` 与 `"false"`，并对其他拼写报错。只有输入本来就是语言值、且目标确实是其真值时才使用 `Boolean()`。

### 用 `||` 覆盖所有假值

> **陷阱:** `count || 10` 会把有效的 `0` 改成 `10`，`enabled || true` 会把 `false` 改成 `true`，`label || "default"` 也会丢掉有意义的空字符串。

**修复方法：** 缺失只指 `null` 或 `undefined` 时使用 `??`。如果空字符串或零在当前领域也算缺失，应写出具名验证条件，让读者看见这项业务规则。

### 把转换成功当成输入有效

> **陷阱:** `Number("")` 得到 `0`，`parseInt("12px", 10)` 得到 `12`。只检查结果不是 `NaN`，会接收契约以外的空文本或带后缀文本。

**修复方法：** 先验证完整输入语法，再转换并检查范围。整数还要根据领域选择 `Number.isInteger()` 或 `Number.isSafeInteger()`；不要用全局 `isNaN()`，因为它会先转换参数。

### 忘记 `+` 的双重语义

> **陷阱:** 表单中的 `"20"` 与数字 `5` 相加得到 `"205"`。若一边是 BigInt、另一边是 Number，数值加法还会抛出 `TypeError`，而不是自动选择一种表示。

**修复方法：** 在计算边界先解析成同一种数值类型，再执行算术。生成消息时使用模板字面量表达字符串意图，不要借用空字符串加法完成转换。

### 混淆值相等与对象内容

> **陷阱:** `===` 比较对象标识，不会递归比较属性；`NaN === NaN` 还会返回 `false`。反过来，`==` 的转换可能让 `0 == false` 成立，却没有证明两个字段表达同一个领域值。

**修复方法：** 普通分支默认用 `===`。`NaN` 用 `Number.isNaN()`，集合成员语义参考具体 API；领域对象则比较经过定义的键或实现符合契约的结构比较。

<!-- deep -->

## 绑定、修改与包装对象

绑定与绑定当前保存的值是模型中的两个部分。把 `let current = 1` 重新赋值为 `current = 2`，改变的是绑定，不是 Number 值 `1`。修改则作用于对象的属性或内部状态。这项区别解释了为什么 `const` 对象仍可修改，而 `let` 保存的 String 仍不可变。

| 操作 | 绑定是否变化 | 现有值是否修改 |
| --- | --- | --- |
| `current = other` | 是 | 否 |
| `record.count = 2` | 否 | 是，前提是写入成功 |
| `text.toUpperCase()` | 否 | 否；它返回新的 String 值 |
| `items.push(value)` | 否 | 是；Array 对象发生变化 |
| `Object.freeze(record)` | 否 | 对象通过普通数据属性变成浅层不可写 |

基本值的属性访问看起来像对象操作，是因为 JavaScript 临时提供了包装行为。因此，`"hi".toUpperCase()` 可以执行，但 String 值并不是对象。这套临时机制不会把原基本值变成可修改记录。

不带 `new` 调用 `String(value)`、`Number(value)` 或 `Boolean(value)` 会执行转换。使用 `new String()`、`new Number()` 或 `new Boolean()` 则会创建包装对象。所有对象都是真值，所以 `new Boolean(false)` 也是真值，几乎从来不是应用真正需要的值。

包装对象与其基本值载荷也不会通过严格相等比较：`new Number(3) !== 3`。普通 API 边界应接收基本值，除非契约明确需要对象标识。与旧代码集成时若要取出包装值，可调用 `value.valueOf()`，然后继续验证得到的基本类型。

`Object.freeze()` 是浅层操作：它阻止对冻结对象自有属性的普通修改，但嵌套对象仍可能可变。冻结是对象策略，不能让本来就不可变的基本值变得“更不可变”。

函数是可调用对象，虽然 `typeof` 为方便起见返回 `"function"`。函数可以拥有属性，也像其他对象一样按标识比较；这个特殊结果并没有产生第九种语言类型。

## 对象到基本值的转换

需要基本值的运算遇到对象时，会执行规范中的 `ToPrimitive` 抽象操作。若对象提供 `[Symbol.toPrimitive](hint)`，运行时先调用它；返回值必须是基本值，否则抛出 `TypeError`。`hint` 可能是 `"number"`、`"string"` 或 `"default"`，它表达语法期望，不是强制要求返回某一种基本类型。

没有自定义钩子时，普通转换会按提示尝试 `valueOf()` 与 `toString()`。字符串提示通常先试 `toString()`，其他提示通常先试 `valueOf()`；只有方法返回基本值时才停止。Date 对默认提示有特殊处理，因此“对象加空字符串总是先调用 `valueOf()`”并不是通用规则。

加法先对两边执行基本值转换。只要转换后有一边是 String，就把两边转为 String 并拼接；否则把两边转为数值后相加。自定义 `Symbol.toPrimitive` 会影响日志、模板、比较与算术，若返回结果随隐藏状态变化，调用点就很难推断，应把这种钩子保持简单且有明确领域含义。

字符串转换 Symbol 时也有一处容易遗漏的差异。`String(symbol)` 会返回描述文本，但隐式字符串拼接和模板插值对 Symbol 值会抛出 `TypeError`。诊断代码需要接受字符串或 Symbol 时，应显式调用 `String(key)`。

## 相等算法的边界

ECMAScript 并非所有 API 都调用同一种相等关系。下面的表格概括四种常见语义；“对象”一栏全部按对象标识比较，不会自动检查属性内容。

| 算法或入口 | 是否强制转换 | `NaN` 与自身 | `+0` 与 `-0` | 常见入口 |
| --- | --- | --- | --- | --- |
| 抽象相等 | 是 | 不相等 | 相等 | `==` |
| 严格相等 | 否 | 不相等 | 相等 | `===`、`indexOf()` |
| SameValue | 否 | 相等 | 不相等 | `Object.is()` |
| SameValueZero | 否 | 相等 | 相等 | `includes()`、Map、Set |

宽松相等不是随机规则，但它的分支多，且对象转换可能调用用户代码。`value == null` 是一个常见而窄的惯用法，只匹配 `null` 与 `undefined`；若团队采用它，应通过 lint 例外和注释保留这项意图。其他跨类型比较通常在边界先转换，再用严格相等更容易审查。

`switch` 的匹配使用严格相等，因此 `case NaN` 永远不会命中 `NaN`。数组的 `indexOf(NaN)` 也找不到 `NaN`，而 `includes(NaN)` 可以。测试查找逻辑时应调用实际生产 API，不能用另一种“看起来等价”的比较代替。

## Number、BigInt 与序列化边界

Number 能精确表示的整数范围有限，`Number.isSafeInteger()` 用于判断整数是否仍有无歧义的表示。超出范围的连续整数可能舍入到同一个 Number，因此先用 `Number(largeText)` 转换、再检查结果是否安全，最多只能拒绝不安全结果，不能恢复已经丢失的数字。需要大整数时，应直接从原始文本构造 BigInt。

BigInt 不表示小数，也不能与 Number 混合进行大多数算术运算。关系比较允许跨这两种数值类型比较，但显式统一类型更能暴露精度策略。把任意 Number 转成 BigInt 还要求该 Number 是整数；若它早已越过安全整数范围，转换只会精确保留那个已经舍入的 Number。

默认的 `JSON.stringify()` 不支持 BigInt，会抛出 `TypeError`。接口必须决定把大整数编码成十进制字符串、限制在安全 Number 范围内，或使用另一个有明确整数类型的协议。读取 JSON 后也不能靠观察一个 Number 判断原始发送方是否已经丢失精度，契约应在传输前确定表示。

`NaN`、`Infinity` 和 `-Infinity` 是 Number 值，但 JSON 会把对象属性或数组元素中的它们写成 `null`。因此，JSON 往返不能保留所有 JavaScript Number。需要区分非有限值时，应在序列化前拒绝它们或使用约定的显式标签。

<!-- /deep -->

[检查点: javascript/data-types](https://codewiki.com/zh/javascript/data-types/#checkpoint)

## 延伸阅读

- [MDN：JavaScript 数据类型和数据结构](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Data_structures)
- [MDN：`typeof`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof)
- [MDN：相等比较与相同性](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Equality_comparisons_and_sameness)
- [ECMAScript 规范：语言类型与值](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html)
- [ECMAScript 规范：`ToPrimitive`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-toprimitive)
