第 08 章 不透明 id 与跨边界序列化
代码基线:commit
a89bca1316(2026-10-06)| DSH0.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 // ✓ 通过,且这次是真的
实测确认三件事:
asserts v is T让编译器在调用点之后把v收窄成T- 收窄的是变量的控制流,不需要
as - 函数体里那个
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。