TypeScript 精读 08:不透明 id 与跨边界序列化

第 08 章 不透明 id 与跨边界序列化

代码基线:commit a89bca1316(2026-10-06)| DSH 0.2.0-rc.1 | 本系列讲次:T7

本章导读

这一章把第 02 章的 Branded 推到它真正的考验面前。

第 02 章我们看到 Branded<B> 能让 SessionId 与 ToolCallId 互不兼容,
而且代价为零(产物为零)。但那时它们都还活在内存里。

现实里 id 要穿过 JSON、穿过进程、穿过文件。第 01 章讲过:类型在编译后
不存在
。那么第 02 章那套保护,在边界另一侧还剩什么?

答案是:什么都不剩,而且没有任何编译器提示。

本章要讲的正是一次具体的"死亡":SessionId 被 JSON.stringify 写成字符串,
被 JSON.parse 读回来,变成了 any。你可以用一句 as SessionId 把它骗回
原形,编译器完全不吭声。品牌机制在序列化边界上彻底失效。

读完本章你要能回答:"那我到底该在哪儿把品牌找回来?"
答案是一个可复用的形状:解析边界的断言函数。


一、回顾:那 39 行标本

[packages/util/brand/src/index.ts``packages/util/brand/src/index.ts:

declare const BRAND: unique symbol  
  
export type Branded<B extends string> = string & { readonly [BRAND]: B }  
export type BrandedNumber<B extends string> = number & { readonly [BRAND]: B }  
  
export function brandString<T extends Branded<string>>(value: string | T): T {  
  return value as T  
}  
export function brandNumber<T extends BrandedNumber<string>>(value: number | T): T {  
  return value as T  
}  

第 02 章已经拆过它的四个零件:unique symbol 提供唯一键、交叉提供"仍然是
原始类型"、泛型 + 约束让调用方声明目标品牌、as 在内部做一次受控转换。

本章要回答的是第 02 章结尾留下的那个问题:这套东西的保质期有多长?


二、品牌的死亡:一次实测

写一段最短的程序:

import { brandString } from '../packages/util/brand/src/index.ts'  
type SessionId = Branded<'SessionId'>  
  
const a = brandString<SessionId>('s1')          // 此刻:SessionId,有品牌  
  
const raw = JSON.stringify({ id: a })            // 此刻:字符串,运行时仍是它  
const parsed = JSON.parse(raw)                  // 此刻:any  

第 01 章说过 JSON.parse 的返回类型是 any。实测确认它有多致命:

const t2: number = parsed.id                     // ✓ 通过  

parsed.id 是 any,所以赋给 number、赋给 SessionId、赋给
boolean 都通过。
品牌、类型、全部消失。

你当然可以骗回来:

const t1: SessionId = parsed.id as SessionId     // ✓ 通过,但这是谎言  

编译器一声不吭。 这是第 01 章那条纪律的终极形态:
as 是断言,而这里没有任何东西支持这个断言——你只是希望它是。


三、品牌在边界上还有什么价值

先说清楚:它在边界这一侧仍然有价值,只是价值范围有限。

实测:品牌 string 与品牌 number 不通用(第 24 行 TS2322):

const n = brandString<SessionId>('x')  
const t4: BrandedNumber<'X'> = n    // error: Type 'SessionId' is not assignable to type 'number'  

更实际的价值在于同进程内的接缝。DSH 有 241 处 Branded<T>,
它们集中在插件之间、服务之间的调用点:

  • 一个插件拿到 remote.session 返回的 SessionId,转交给另一个插件
  • 这两个插件在同一个进程、同一个 TypeScript 程序里
  • 于是品牌在它们之间是活的,跨不过去就报错

所以准确的描述是:

品牌保护的是"编译期已知边界"上的混淆,不是"数据边界"上的混淆。
凡是编译期能同时看见两端的,品牌有效;
凡是要穿过 JSON / 进程 / 文件的,品牌失效。

本章第一条纪律

看到 Branded<T>,先问两端是否在同一个 TS 程序里。
是 → 它在干活;否 → 它只是给人看的注解,运行时该校验还是要校验。


四、断言函数:把校验写进类型

那品牌丢了怎么办?答案是把它找回来,方式是一个特殊的函数形态。

function assertSessionId(v: unknown): asserts v is SessionId {  
  if (typeof v !== 'string') throw new Error('bad')  
}  
  
assertSessionId(parsed.id)                    // 运行时校验  
const t3: SessionId = parsed.id               // ✓ 通过,且这次是真的  

实测确认三件事:

  1. asserts v is T 让编译器在调用点之后把 v 收窄成 T
  2. 收窄的是变量的控制流,不需要 as
  3. 函数体里那个 if 是真正的运行时检查——类型在这里重新获得它丢失的东西

对比第二章那个 as:

写法 编译器 运行时 适合的场景
v as T 无条件放行 无检查 你已经检查过,T 就是它
asserts v is T 调用点后收窄 有检查 边界解析,值不可信

这就是第 01 章第六节那个结论的落地形态:
「parse/config、wire、durable/file 这些边界必须校验」——
而 asserts v is T 是把"校验"和"类型"缝在一起的那个具体工具。

本章第二条纪律

边界解析用 asserts v is T,不要用 as T。
前者把校验写进函数体、让编译器在调用点后收窄;
后者只是让编译器闭嘴。
二者产出的类型完全相同,运行时行为天差地别。


五、三种不透明类型的取舍

写"这个 id 不是普通字符串"有几种做法,实测取舍如下。

5.1 unique symbol 键(本系列采用)

type Branded<B extends string> = string & { readonly [BRAND]: B }  
优点 缺点
原始类型行为完全保留(比较、拼接、打印) 需要一个 unique symbol 声明
品牌是字面量,差异永远可辨(第 06 章 E-04) 运行时无身份——这既是优点也是第 2 节的病因

5.2 unique symbol 类型直接做 id

declare const SESSION_ID: unique symbol  
type SessionId = string & { readonly [SESSION_ID]: true }  

更短,但两个不同的 unique symbol 类型不互认,于是
Branded<'A'> 与 Branded<'B'> 在"标签"这一层反而更难读——
错误消息会变成两串 symbol 名字而不是 'A' / 'B'。DSH 选前者。

5.3 类

class SessionId { constructor(private readonly v: string) {} }  
优点 缺点
运行时真的有身份,可以带方法 产物不为零(第 01 章:类会生成代码)
new 强制构造 每个 id 一个堆分配;JSON 往返同样要手写 toJSON/fromJSON

第 01 章那条"活下来的东西"清单在这里有了决策价值:Branded 之所以用交叉
而不是类,正是因为它在产物里什么都不留
。DSH 有 241 处 Branded,如果
全部改成类,运行时开销会立刻可见。

本章第三条纪律

选不透明 id 的实现时,先问"它需不需要运行时身份"。
不需要 → 交叉 + unique symbol(产物为零);
需要(比如要带方法、要参与相等比较)→ 才考虑类。
为了一个纯类型标记付出运行时开销,是最常见的过度设计。


六、往返编解码:一个完整的形状

把第 2 节到第 4 节拼起来,得到一个可复用的模板。以 DSH 真实使用的
[packages/webhook/webhook/src/brand.ts``packages/webhook/webhook/src/brand.ts 为参照:

export type WebhookRuleId = Branded<'WebhookRuleId'>  
export type WebhookSourceId = Branded<'WebhookSourceId'>  
export type WebhookDeliveryId = Branded<'WebhookDeliveryId'>  
  
/**  
 * Brand a webhook rule id.  
 * @param value - non-empty rule identifier validated at registration.  
 * @returns the same string with its compile-time brand.  
 */  
export function WebhookRuleId(value: string): WebhookRuleId {  
  return value as WebhookRuleId  
}  

它比第 02 章那个泛型版更进一步:三个品牌各有一个具名构造函数,
而不是一个通用的 brandString<T>。理由很实际——

  • brandString<WebhookRuleId>(x) 要求调用方写两次这个名字
  • WebhookRuleId(x) 写一次,而且返回值类型就是参数所命名的品牌
  • 它同时是文档位置:JSDoc 说清了 "non-empty rule identifier validated at registration",
    也就是校验发生在注册时,不在构造时

第四个 brand 是 WebhookDeliveryId,它的 JSDoc 更进一步:

The runtime assigns no deduplication semantics.

明确声明了"这个 id 在运行时没有任何特殊含义"——把第 3 节的结论写进了代码。

完整的往返形状是这样的:

   编译期可信                        边界                        编译期可信  
┌──────────────────┐          ┌──────────────────┐          ┌──────────────────┐  
│ WebhookRuleId    │  JSON    │  unknown / any   │  解析    │ WebhookRuleId    │  
│ (有品牌)       │ ───────> │ (品牌全无)      │ ───────> │ (有品牌)       │  
└──────────────────┘          └──────────────────┘          └──────────────────┘  
                                   ▲  
                                   └── asserts v is T / parse 函数在这里  

中间那一格是不可信的。 第 01 章讲过"擦除",第 02 章讲过"品牌",
本章讲的是它们相遇时的后果——品牌在那一格是零,而校验必须在那里补上。

本章第四条纪律

任何 Branded<T> 跨过 JSON / 进程 / 文件时,
边界上必须有一个把 unknown 变回 Branded<T> 的函数,
且该函数内部做真实校验。没有它,品牌只是装饰。


七、本章小结

  • 品牌在序列化边界上彻底失效,且零诊断。JSON.parse 返回 any,
    parsed.id 可以赋给任何类型。
  • 品牌保护的是"编译期同时可见的两端",即同进程内的插件/服务接缝。
    241 处 Branded 的价值都在这一侧。
  • asserts v is T 是把品牌找回来的标准工具:它在调用点后收窄控制流,
    函数体内做真实运行时校验。
  • as T 与 asserts v is T 产出同一种类型,运行时行为完全不同。
    边界解析只能用后者。
  • 三种不透明实现的取舍取决于"要不要运行时身份":
    交叉 + unique symbol 产物为零;类有真实身份但产生运行时开销。
  • 完整往返 = 编译期可信 → 不可信 → 编译期可信,中间那一格必须有人负责。

下一章讲最后一类日常工程问题:异步的类型建模。101,160 处 async/await
是 DSH 的真实体量,而它系统性地教不到这一块。


本章实验(附录)

实验脚本:[labs/M8-brand-lab.tslabs/M8-brand-lab.ts

运行(在仓库根目录):

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "ts-源码精读/labs/M8-brand-lab.ts"  

这个实验做什么:把第 2 节的"死亡"做成可复现的三行对照——
同一个 parsed.id 赋给 number / SessionId / boolean 全部通过。
然后证明断言函数能把它救回来,且救回来的不是谎言。

预期输出(本机实测,任何一行对不上就说明基线变了):

── 组 1:品牌的保护范围  
  ✓ 两个品牌在编译期互不兼容  
  ✓ 品牌 string 与品牌 number 不通用  
  ✓ brandString 产物的类型即为目标品牌  
  
── 组 2:序列化边界上的死亡(本章核心)  
  ✓ JSON.parse 的结果赋给任意类型都通过(any)  
  ✓ as SessionId 可以毫无阻力地骗回品牌  
  ✓ 边界上没有任何编译期提示(上面三条全通过即证)  
  
── 组 3:断言函数把它救回来  
  ✓ asserts v is T 在调用点后收窄控制流  
  ✓ 收窄后不需要任何 as  
  ✓ 断言失败路径是真实的运行时抛出  
  
── 组 4:真实 brand 文件  
  ✓ webhook brand.ts 为每个品牌提供具名构造函数  
  ✓ 构造函数 JSDoc 明确校验发生在注册时而非构造时  
  ✓ webhook brand 的类型产物为零(构造后仍是普通字符串)  
  
全部断言通过。  

本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。