本节目标:读完这一节,你能说清
JSON.stringify与JSON.parse这一对函数在类型上的不对称,能默写 JSON 序列化的失真清单与对应的真实报错,会用编解码器把「线上格式」与「内存类型」显式分开,讲透版本迁移、replacer/reviver与循环引用的处理方式,并能在 JSON 与二进制格式之间做出有依据的取舍。
10.2 序列化与反序列化类型
上一节处理的是数据「进来时对不对」,用的是 schema 与守卫。但数据还有另一条更隐蔽的路径:先写出去,再读回来。存进 Redis、塞进消息队列、写进 localStorage、落到 MySQL 的 JSON 列——每一次写出都是类型的一次泄露。
序列化边界最反直觉的地方在于:它是双向的,而两个方向在类型上并不对称。写出去时你有一个 Date,读回来时你只有一个字符串,类型系统对此一言不发。
序列化是类型的单向门
先看一段没有任何报错的代码:
interface Session {
userId: number;
createdAt: Date;
}
const s: Session = { userId: 1, createdAt: new Date() };
const raw = JSON.stringify(s); // 类型是 string
const back = JSON.parse(raw) as Session; // 类型是 Session,但它是假的
console.log(back.createdAt instanceof Date); // false
console.log(typeof back.createdAt); // "string"
console.log(back.createdAt.getTime());
// 💥 TypeError: back.createdAt.getTime is not a function
JSON.parse(raw) as Session 通过了全部类型检查,因为 JSON.parse 的返回类型是 any——any 可以赋给任何类型。这就是 any 在边界上最危险的地方:它不是「未知」,而是「随便你说是啥」。
更糟的是,这个错误往往不在序列化的地方暴露,而在几个调用栈之后:
TypeError: back.createdAt.getTime is not a function
at formatSession (/app/src/session.ts:42:31)
排查时你会怀疑 formatSession,而真正的问题在两小时前写出的那行 as。
JSON.parse 的 any 代价
JSON.parse 的签名是 parse(text: string, reviver?): any。这个 any 是 TypeScript 标准库里最被诟病的返回类型之一,但改成 unknown 会破坏海量既有代码,所以至今未变。工程上的对策是用一层包装把它封死:
function parseJson(text: string): unknown {
return JSON.parse(text) as unknown;
}
const data = parseJson(raw); // 类型是 unknown,不能直接点属性
// data.createdAt; ❌ Object is of type 'unknown'.
unknown 与 any 的差别在这里体现得淋漓尽致:unknown 强迫你在使用前做一次判定,而那次判定正好是你需要校验的地方。这与上一节的结论一致——边界上唯一正确的返回类型是 unknown,不是 any。
JSON 的失真清单
JSON 只有六种值:null、布尔、数字、字符串、数组、对象。任何超出这个范围的东西都会被静默地变形或直接抛错。下面这张表值得背下来:
| 值 | JSON.stringify 的结果 | 读回后 |
|---|---|---|
undefined(对象属性) | 该属性被删除 | 属性不存在 |
undefined(数组元素) | null | null |
NaN / Infinity | null | null |
Date | ISO 字符串 | string |
BigInt | 抛 TypeError | —— |
Map / Set | {} | 空对象 |
RegExp | {} | 空对象 |
函数 / Symbol(属性) | 该属性被删除 | 属性不存在 |
| 循环引用 | 抛 TypeError | —— |
有 toJSON 的对象 | 调用其结果 | 依结果而定 |
两类错误的性质完全不同:BigInt 与循环引用是快速失败,你会立刻知道;而 undefined 被删、Map 变成 {}、NaN 变成 null 是静默失真,代码照跑,数据错了。
const bad = { count: NaN, big: 10n };
// 第一个问题
JSON.stringify({ count: NaN }); // '{"count":null}'
// 第二个问题
JSON.stringify({ big: 10n });
// 💥 TypeError: Do not know how to serialize a BigInt
注意错误信息的措辞:Do not know how to serialize a BigInt——这是运行时的 TypeError,不是编译错误。类型检查不会拦你。
日期:最经典的失真
Date 值得单独拎出来,因为它是唯一一个「有原生 JSON 表示、但不是对称的」内置类型。toJSON() 把它变成 ISO 字符串,但 JSON.parse 不知道要把它变回来:
const now = new Date("2026-10-06T10:00:00+08:00");
const raw = JSON.stringify({ at: now });
// '{"at":"2026-10-06T02:00:00.000Z"}'
const back = JSON.parse(raw) as { at: Date };
console.log(back.at); // "2026-10-06T02:00:00.000Z" —— 字符串,不是 Date
顺带一个容易踩的坑:toISOString 会统一转成 UTC。如果你的业务代码在本地时区格式化这个字符串,就会看到 8 小时的偏移。序列化本身是无损的(+08:00 与 Z 表示同一时刻),失真发生在「谁负责格式化」的约定上。
修法是显式的编解码器。这引出了本节的中心工具。
编解码器:把线上格式与内存类型分开
核心思想是把「内存里的类型 A」与「线上的类型 W」当成两个类型参数:
interface Codec<A, W = unknown> {
encode(value: A): W;
decode(wire: W): A;
}
const DateCodec: Codec<Date, string> = {
encode: (d) => d.toISOString(),
decode: (s) => new Date(s),
};
这两个类型参数的意义在于:它们把一个隐藏的假设写进了签名。encode 的返回值类型是 string 而不是 unknown,意味着这个 codec 只能用在 JSON 兼容的位置上;decode 的输入是 string,意味着它拒绝接受任意值。
Codec 还可以组合成对象级别的 codec:
type Shape = Record<string, Codec<any, any>>;
type Encoded<S extends Shape> = { [K in keyof S]: ReturnType<S[K]["encode"]> };
type Decoded<S extends Shape> = { [K in keyof S]: ReturnType<S[K]["decode"]> };
function struct<S extends Shape>(shape: S): Codec<Decoded<S>, Encoded<S>> {
return {
encode: (value) => {
const out: Record<string, unknown> = {};
for (const [key, codec] of Object.entries(shape)) {
out[key] = codec.encode((value as Record<string, unknown>)[key]);
}
return out as Encoded<S>;
},
decode: (wire) => {
const out: Record<string, unknown> = {};
for (const [key, codec] of Object.entries(shape)) {
out[key] = codec.decode((wire as Record<string, unknown>)[key]);
}
return out as Decoded<S>;
},
};
}
用法:
const SessionCodec = struct({
userId: NumberCodec,
createdAt: DateCodec,
});
type Session = ReturnType<typeof SessionCodec.decode>;
// type Session = { userId: number; createdAt: Date }
const wire = SessionCodec.encode({ userId: 1, createdAt: new Date() });
// wire 的类型是 { userId: number; createdAt: string },直接 JSON.stringify 即可
const s = SessionCodec.decode(JSON.parse(raw));
console.log(s.createdAt instanceof Date); // true
类型安全的关键在于 Session 是从 codec 推导出来的,而不是手写的。 手写 interface Session { createdAt: Date } 再配一个 DateCodec,这两者之间没有任何强制关联,迟早漂移;而 ReturnType<typeof SessionCodec.decode> 让它们同源。
DateCodec.decode 其实还应该做校验——new Date("垃圾") 不会抛错,而是产生一个 Invalid Date。这就是为什么 codec 与上一节的 schema 通常合并成一个东西:decode 既转换又校验。
const DateCodec: Codec<Date, string> = {
encode: (d) => d.toISOString(),
decode: (s) => {
const d = new Date(s);
if (Number.isNaN(d.getTime())) throw new ValidationError([`非法日期: ${s}`]);
return d;
},
};
版本化:schema 会演进
只要数据落盘或跨进程传递,schema 就一定会演进。这里有两条路线,选哪条取决于数据是谁写的:
| 路线 | 做法 | 适用 |
|---|---|---|
| 带版本号 | 每个对象含 version 字段,读时迁移 | 落盘、长期存储 |
| 向后兼容 | 新字段可选,旧读者忽略未知字段 | 消息队列、短生命周期 |
| 严格拒绝 | 未知字段报错 | 安全敏感、内部契约 |
带版本号的迁移写法:
type V1 = { version: 1; name: string };
type V2 = { version: 2; name: string; tags: string[] };
type Latest = V2;
function migrate(raw: unknown): Latest {
if (typeof raw !== "object" || raw === null) throw new ValidationError(["期望对象"]);
const v = raw as { version?: unknown; name?: unknown; tags?: unknown };
switch (v.version) {
case 1:
return { version: 2, name: String(v.name), tags: [] };
case 2:
return { version: 2, name: String(v.name), tags: (v.tags as string[]) ?? [] };
default:
throw new ValidationError([`未知版本: ${String(v.version)}`]);
}
}
注意 default 分支——它让未来新增的版本在读旧代码时快速失败,而不是被误当成 v2 解析。没有这个分支,一个 v3 的数据可能被 v2 的代码静默读错。
一个实践建议:迁移函数只做「旧 → 新」的单向转换,不做「新 → 旧」。降级(写回旧格式)几乎总是错的,因为它要求旧读者理解新语义,而这正是版本演进想避免的事。
循环引用与 replacer / reviver
JSON.stringify 遇到循环引用会直接抛错:
const a: any = { name: "a" };
a.self = a;
JSON.stringify(a);
// 💥 TypeError: Converting circular structure to JSON
用 replacer 可以把引用替换成标识符:
const seen = new WeakSet<object>();
const raw = JSON.stringify(a, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
});
// '{"name":"a","self":"[Circular]"}'
但请记住:替换成占位符意味着信息已经丢失,reviver 无法还原成真正的循环结构。真正需要保留环的场景(比如图结构、ORM 实体)应该换序列化格式,而不是硬套 JSON。
reviver 则是在 JSON.parse 阶段做转换的地方,它最常见的用途是「把看起来像日期的字符串还原成 Date」:
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const data = JSON.parse(raw, (key, value) => {
if (typeof value === "string" && ISO.test(value)) return new Date(value);
return value;
});
但这个写法非常危险,因为它是基于值的猜测,而不是基于 schema 的约定。一个恰好长成 ISO 格式的用户输入字符串(比如用户填的备注)会被静默变成 Date,类型也就错了。同一份数据用不同的 reviver 会得到不同的类型,这正是「隐式约定」的典型危害。
结论:reviver 只适合你已经完全掌控数据形状的内部场景。对外部数据,请用 codec 显式声明哪个字段是日期。这条原则与 9.2 条件导出与 bundler 语义
里讲过的「显式优于隐式」是同一回事。
二进制与跨语言序列化
JSON 的三个固有短板——体积大、无类型、不支持二进制——在跨语言或高吞吐场景下会变成瓶颈。此时的选择通常是:
| 格式 | 类型 | 体积 | 读回类型 | 典型场景 |
|---|---|---|---|---|
| JSON | 弱(自描述) | 大 | 需 schema | 调试友好、HTTP API |
| Protobuf | 强(.proto) | 小 | 由代码生成 | 微服务、跨语言 |
| MessagePack | 弱 | 中 | 需 schema | 替代 JSON 降体积 |
| CBOR | 弱 | 中 | 需 schema | IoT、二进制友好 |
关键认知:只有 Protobuf 这类「schema 在编译期」的格式,才能让类型安全延伸到运行时边界之外——因为 .proto 是唯一事实来源,生成的 TypeScript 类型与运行时解码器同源,读回来的数据天然满足类型。而 MessagePack、CBOR 只是更快的 JSON,它们把 Date 编码成扩展类型(tag 0x0d),但读回时仍要你指定 schema。
如果只解决「Date 与 BigInt 的失真」而不想引入 IDL,一个轻量方案是自定义标记类型:
type Tagged<T extends string, V> = { $type: T; value: V };
const tag = <T extends string, V>(t: T, v: V): Tagged<T, V> => ({ $type: t, value: v });
const encoded = JSON.stringify({ at: tag("Date", new Date().toISOString()), big: tag("BigInt", "10") });
// '{"at":{"$type":"Date","value":"..."},"big":{"$type":"BigInt","value":"10"}}'
$type 前缀让 reviver 有了明确的判据(而不是靠正则猜),同时保留了 JSON 的可读性。代价是体积膨胀与所有读者都要认识这些标记。
常见坑与报错对照
| 报错 / 现象 | 原因 | 处理 |
|---|---|---|
Do not know how to serialize a BigInt | BigInt 无 JSON 表示 | 转字符串或用 codec |
Converting circular structure to JSON | 循环引用 | 换格式或用 replacer |
x.getTime is not a function | Date 读回成 string | DateCodec.decode |
| 属性莫名消失 | 值为 undefined | 用 null 或显式 codec |
数值变 null | NaN / Infinity | 编码前归一化 |
Map 读回成 {} | JSON 无 Map 表示 | 编码为 [k, v][] |
| 时区偏移 8 小时 | toISOString 转 UTC | 明确格式化责任方 |
| 未知版本被当旧版解析 | 迁移缺 default | 抛错快速失败 |
最后提一句 structuredClone。它是浏览器与 Node 内置的结构化克隆,支持 Date、Map、Set、RegExp、循环引用,但不支持函数、Symbol、DOM 节点,且克隆后的原型链会保留:
const clone = structuredClone({ at: new Date(), m: new Map([[1, "a"]]) });
clone.at instanceof Date; // true —— 不会失真
clone.m instanceof Map; // true
它能替代一部分序列化场景(比如 postMessage 传参、深拷贝),但不能替代持久化——它产出的是内存对象,不是可存储的字节。区分「深拷贝」与「序列化」是选型时的第一步:前者保类型不保字节,后者保字节不保类型。关于格式对比的更多细节,可以延伸阅读 序列化格式对比
。
小结
这一节我们走完了数据「写出再读回」的整条路径:
- 不对称性:写出时
JSON.stringify知道类型,读回时JSON.parse只返回any,缝隙就产生在这里。 unknown优先:边界上永远不要把any暴露给调用方,包装成unknown强迫调用方判定。- 失真清单:
Date→ string、undefined属性被删、NaN→null、Map/Set→{}、BigInt与循环引用直接抛错。 - 编解码器:用
Codec<A, W>把内存类型与线上格式分开,并让领域类型从 codec 推导出来,而不是手写。 - 版本迁移:单向迁移 +
default快速失败,避免未知版本被静默误读。 reviver是隐式约定:基于值的猜测会在用户输入上出错,请显式声明字段语义。- 格式取舍:JSON 可读但失真,Protobuf 类型安全但需 IDL,
structuredClone保类型但不持久化。
到这里,我们处理的数据都还来自可预期的来源——自己的数据库、自己的 codec。但真实系统的入口往往没有这么客气:HTTP body 是任何人构造的、环境变量可能被注入、第三方 API 的响应格式随时会变。下一节我们把「不可信输入」单独拎出来,看看信任边界应该划在哪里,以及原型污染、__proto__、深合并这些具体攻击面如何在类型层面设防。
阅读导航:上一节:10.1 类型守卫与验证库原理 · 下一节:10.3 边界数据与不可信输入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。