《TypeScript高级编程》10.2 序列化与反序列化类型

本节讲数据写出再读回这条边界上类型如何失真。先说清 JSON.stringify 与 JSON.parse 在类型上的不对称,再列出 Date、BigInt、undefined、NaN、Map、循环引用的失真清单与报错,然后用编解码器 Codec 把线上格式与内存类型显式分开,讲透版本迁移、replacer/reviver 的用法与二进制格式取舍。读完你能为持久化与消息协议设计出类型安全的边界。

本节目标:读完这一节,你能说清 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(数组元素)nullnull
NaN / Infinitynullnull
DateISO 字符串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弱中需 schemaIoT、二进制友好

关键认知:只有 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 BigIntBigInt 无 JSON 表示转字符串或用 codec
Converting circular structure to JSON循环引用换格式或用 replacer
x.getTime is not a functionDate 读回成 stringDateCodec.decode
属性莫名消失值为 undefined用 null 或显式 codec
数值变 nullNaN / 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 边界数据与不可信输入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes