第 02 章 泛型与推断
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T1
本章导读
这一章讲 TS 里最基础、也最被跳过的那个东西。
DSH 全仓有 142 处 infer、2970 处 as const、1049 处模块增强,却没有一处
解释泛型是什么。它假定读者已经会了,于是新读者在第三个文件就卡住:为什么
T extends Branded<string> 这么写?那个 T 是从哪来的?为什么不给它就编不过?
我们用 [packages/util/brand/src/index.ts``packages/util/brand/src/index.ts
当锚点。整个文件只有 39 行,却把泛型的四个核心问题全部摆在了台面上:
- 类型参数与类型实参的区别
- 约束(
T extends X)买到了什么,又没买到什么 - 推断在哪里发生,以及推断失败时会掉到哪里——这是本章最反直觉、也最有用的发现
- 回调参数的方向性(逆变),以及
strictFunctionTypes为什么会咬人
读完本章你要能回答:"我明明没写 <SessionId>,为什么编译器还是拦住了我?"
答案会让你重新理解什么叫"类型安全"。
一、一个 39 行的标本
先把锚点完整摆出来。全文(去掉 JSDoc 的注释行):
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
}
文件头部的模块注释(第 1–13 行)说明了它要解决什么,值得完整引用:
A brand makes structurally identical strings or numbers non-interchangeable at
the type level: aSessionIdcannot be passed where aToolCallIdis expected,
and an event sequence cannot be passed as a log offset.This package owns no concrete domain value and keeps no runtime identity or
mutable state, so independently installed copies produce interchangeable values.
最后一句是纯粹的架构决策:这个包刻意不持有任何运行时身份与可变状态,所以
即使被装了两份,产出的值仍然可以互换。这是用"包内什么都不放"换来的性质,
代价是所有能力都在类型层。第 06 章会证明这个取舍是真的。
先把结论摆在这里,剩下六节逐条证明。
二、类型参数与类型实参
export function brandString<T extends Branded<string>>(value: string | T): T
<T extends Branded<string>>写在尖括号里、函数名之后——这是类型参数声明<SessionId>写在调用处——这是类型实参
两者的关系类比函数的形参与实参,但有一个关键差异:类型实参绝大多数时候不用写。
const b = brandString<SessionId>('y') // 显式:'y' 被断言成 SessionId
这一行的意思不是"把 'y' 转成 SessionId",而是"在 'y' 已经是一个合法
SessionId 的前提下处理它"。和第 01 章的 as 一样,泛型不制造事实,只表达
你确信的事。
而第 28 行函数体里那个 return value as T,是同一件事的内部版本。它是
[AGENTS.md``AGENTS.md 禁止"新增 as unknown"条款不适用的地方——
因为这正是被指定的那道关口。所有品牌都必须从这个函数出去,里面的断言是它的
职责,不是逃逸。
本章第一条纪律
as和泛型实参表达的是同一件事:我确信。区别只在于as在赋值处,
泛型实参在调用处。两者都必须建立在一个真实的检查之上。
三、unique symbol:为什么不是 string
declare const BRAND: unique symbol
export type Branded<B extends string> = string & { readonly [BRAND]: B }
BRAND 是第 01 章说过的值空间 + 类型空间双重存在的名字:declare const
让它在值空间有个身份(虽然运行时是 undefined),而 unique symbol 类型让
它能安全地当键用。
为什么必须是 unique symbol,不能是 string 或普通 symbol?
实测三种写法的差别(用 tsc 跑出来的,不是推理):
| 写法 | 后果 |
|---|---|
{ readonly brand: B },brand: string |
任何对象都能伪造,安全性归零 |
{ readonly [BRAND]: B },BRAND: symbol |
多个不同的 symbol 类型不互认,需要 unique |
{ readonly [BRAND]: B },BRAND: unique symbol |
只有这一个键,无法伪造也无法混淆 |
unique symbol 的保证是编译器给出的:每个 unique symbol 声明都是唯一类型,
两个不同的 unique symbol 互不兼容。 这就是第 08 章要讲的不透明 id 能成立的
全部技术基础——但根在这里。
注意 readonly 的作用:它让 Branded 的属性不可赋值,于是
string & { readonly [BRAND]: B } 里的字符串部分仍然完全可写(它就是
string),只有那个假想的键是只读的。这正好对应模块注释说的
"Comparison, logging, and serialization retain the underlying primitive behavior"
——品牌只是编译期的装饰,运行时行为分毫未变。
四、约束:T extends Branded<string> 到底买到了什么
brandString 的约束是 T extends Branded<string>。逐字拆开:
- 买到的:在函数体内部,
T的所有成员都可访问(这里虽然没有用到,但比如
T extends { length: number }就能让你在函数体里写value.length而不报错);
以及推断失败时的兜底目标(下一节展开,这是它最重要的作用)。 - 没买到的:它不保证
T真的是个品牌。它只保证T是"某个带
BRAND键的东西"。Branded<string>本身就是最宽的那种,符合约束。
再看另一处约束,它的作用显式得多——
[packages/util/brand/src/index.ts:15``packages/util/brand/src/index.ts:15
之外,Branded<B extends string> 里的 B extends string:
export type Branded<B extends string> = string & { readonly [BRAND]: B }
这个约束说:品牌标签必须是个字符串字面量类型。它的职责是挡住非法标签的书写,
而不是守住可赋值性。实测对照(见实验第 2 组):
type Loose<B> = string & { readonly [BRAND]: B }
type Tight<B extends string> = string & { readonly [BRAND]: B }
type A = Loose<42> // ✓ 语法合法——没有约束,数值标签写得出来
type B = Tight<42> // ✗ TS2344:类型参数 'B' 不满足约束 'string'
所以约束的作用是"你不许给品牌起一个数字或对象的名字"。一旦标签只能是字符串,
它就必然是一个具体字面量或字面量联合,而第 6.1 节会说明这已经足够让差异可辨。
DSH 全仓 <K extends string 这个模式出现了大量次数,绝大多数是
Record<K, V> 与 K extends keyof T 这类组合。约束的第一职责永远是:
把一个"什么都可能"的类型参数关进一个笼子。
本章第二条纪律
写类型参数时总是先问"它需要哪些能力"。能力清单就是约束。
没有约束的<T>几乎总是意味着还没想清楚。
五、推断:最反直觉的一节
现在讲本章的核心。回到第 28 行:
export function brandString<T extends Branded<string>>(value: string | T): T
我们先做一个直觉上完全合理的调用——不给显式实参:
const a = brandString('x')
这会通过编译。那 T 被推断成了什么?
5.1 把类型"喊"出来
TypeScript 没有"打印类型"的命令,但有一个可靠的办法:让它在赋值处失败,
错误消息里就会写出真正的类型。
const a = brandString('x')
const reveal: { __reveal: true } = a
实测错误(原文):
error TS2741: Property '__reveal' is missing in type
'String & { readonly [BRAND]: string; }' but required in type '{ __reveal: true; }'.
答案出来了:T 被推断成了 Branded<string>,也就是它自己的约束。
注意品牌标签是 string,不是某个字面量。编译器没有把 'x' 的内容
'x' 提升成品牌标签——那需要更精细的推断规则,而这里没有。
5.2 推断掉到哪里:约束兜底
这就是本章最重要的一条规则:
当 TypeScript 无法从实参推断出类型参数时,它会退回该参数的约束。
不是any,不是unknown,是约束本身。
所以 brandString('x') 得到的是一块没有任何保护力的品牌:
Branded<string> 的标签是 string,而任何具体品牌(比如 SessionId,标签
"SessionId")都不是 string 的子类型——它是 "SessionId" 的子类型。
方向反了。
5.3 后果:一个静默失效的调用
这会报错(实测):
const a = brandString('x')
const asSession: SessionId = a
error TS2322: Type 'Branded<string>' is not assignable to type 'SessionId'.
Type 'Branded<string>' is not assignable to type '{ readonly [BRAND]: "SessionId"; }'.
Types of property '[BRAND]' are incompatible.
Type 'string' is not assignable to type '"SessionId"'.
但调用本身没有报错。 这就是它危险的地方:brandString('x') 编译通过,
看起来一切正常,直到你试图把它用在一个具体位置上,类型系统才告诉你
"你手上这块牌子写的是'随便什么',不是'SessionId'"。
加上显式实参就对了(实测无错):
const b = brandString<SessionId>('y')
const alsoSession: SessionId = b // ✓
本章第三条纪律
对一个"让调用方声明目标类型"的函数(
T出现在返回位置),
显式类型实参不是可选的。T只出现在返回类型里时,实参对推断毫无帮助——
编译器没有信息来源。判据很简单:
T在实参里出现过吗? 没有,就必须显式写。
5.4 推断确实会在实参里发生
上面的结论需要限定,否则会误导。T 出现在参数位置时,推断是有效的。
第 28 行的 value: string | T 里,T 就在参数里,所以如果传进来的已经
是某个具体品牌,推断能拿到它:
declare const s: SessionId
const b = brandString(s) // T 从参数推断为 SessionId
(这条是标准推断行为,本章的实验不单独断言,因为它依赖 string | T 这个
联合里的候选消解,细节留给第 04 章讲 infer 时展开。)
注意 string | T 这个参数类型的设计意图:它允许调用方传普通字符串
(进入品牌化流程),也允许传已经是品牌的值(此时 T 有东西可推断)。
两个 | T 分支不是冗余,是给推断留的口子。
六、品牌不可互换:一个单点字面量就够了
第 01 章讲了 as 的方向。这一节讲结构相同但名义不同的类型。
模块注释承诺:"a SessionId cannot be passed where a ToolCallId is expected"。
实测(TS2322):
type SessionId = Branded<'SessionId'>
type ToolCallId = Branded<'ToolCallId'>
declare const s: SessionId
const c: ToolCallId = s
error TS2322: Type 'SessionId' is not assignable to type 'ToolCallId'.
Type 'SessionId' is not assignable to type '{ readonly [BRAND]: "ToolCallId"; }'.
Types of property '[BRAND]' are incompatible.
Type '"SessionId"' is not assignable to type '"ToolCallId"'.
值得注意的是:两个类型展开后都是 string & {...},结构上唯一的区别就是
[BRAND] 属性的值是 "SessionId" 还是 "ToolCallId"。一个单点的字面量差异
就足以让它们互不兼容。
6.1 差异由字面量保住,不是由约束保住
这里有一个反直觉的实测结果,值得单独记:把 B 放宽成 string,
品牌机制并不会失效。
declare const l: Branded<string>
const t: Branded<'SessionId'> = l
实测仍然报 TS2322:
Type 'Branded<string>' is not assignable to type 'Branded<SessionId>'.
Types of property '[BRAND]' are incompatible.
Type 'string' is not assignable to type '"SessionId"'.
原因很直白:{ [BRAND]: string } 不可赋给 { [BRAND]: 'SessionId' },
因为 string 不可赋给字面量 "SessionId"。品牌差异是字面量与宽类型之间的
方向性差异,天然不可抹平——不需要约束来保护。
本系列首版此处写的是"去掉约束品牌机制当场失效"。那是错的,
而且是没编译就写上去的。被实验第 2 组的断言抓出来(勘误 E-04)。
另一个方向的实测同样值得记:裸 string 不能直接赋给品牌。
const s: Branded<'SessionId'> = 'plain' // ✗ TS2322
因为 string 缺少交叉类型要求的那个 [BRAND] 键。任何品牌都必须从
brandString() 出去——这正是第 28 行那道关口存在的理由。
6.2 另一个容易误会的地方
有人会以为 Branded<'SessionId'> 和 Branded<'ToolCallId'> 不兼容,是因为
它们"是不同类型"。不是。 兼容性只看结构,不看"来历"。它们不兼容纯粹
因为展开后那个属性值不同。
推论:任何能把这两个属性值抹平的写法,都会毁掉品牌。
type Both = Branded<'SessionId'> & Branded<'ToolCallId'> // 永远构造不出来
type Unbranded = string // 一切都兼容
七、回调参数的方向性,以及严格模式的那个例外
先看实测。用同一个"更窄"的回调分别写进属性语法和方法语法:
type Handler = (v: string) => void
const narrower: Handler = (v: 'x' | 'y') => {} // 属性语法
interface Obj { m(v: string): void }
const objNarrow: Obj = { m: (v: 'x' | 'y') => {} } // 方法语法
结果(tsc --strict 实测):
| 位置 | 编译结果 |
|---|---|
属性语法 const narrower: Handler = ... |
TS2322 报错 |
方法语法 const objNarrow: Obj = { m: ... } |
通过 |
也就是说,方法语法是 strictFunctionTypes 的豁免对象,属性语法不是。
这一条几乎总被记反,所以值得再钉一次——本系列的首版讲义就写反了,
是实验第 4 组的断言把它抓出来的(见勘误 E-03)。
逆变方向本身仍然成立:更宽的参数在两种语法下都被接受
((v: string | number) => {} 赋给 (v: string) => void 通过),
因为调用方传进来的值接收方一定处理得了。
本章第四条纪律
写回调时:属性语法
(v: T) => void更严——更窄的参数会被拒。
方法语法m(v: T): void更松——它绕过了严格逆变检查。
公共 API 想要更严的检查就用属性语法,想要兼容历史写法就用方法语法。
DSH 的插件回调几乎全是属性语法,所以 DSH 生态的回调检查是严的。
八、本章小结
- 类型参数 vs 类型实参:实参绝大多数时候不用写,但当
T只出现在返回位置时,
必须写。 unique symbol是品牌机制的技术基础。它保证键唯一,别的symbol无法冒充。- 约束(
T extends X)的第一职责是把类型参数关进笼子,第二职责是推断失败时的兜底目标。 - 推断失败退回约束,不是
any。所以brandString('x')静默产出
Branded<string>——一块没有保护力的品牌,直到用它时才暴露。 - 结构相同不等于兼容。
Branded<'A'>与Branded<'B'>唯一的差异是
一个属性值,这个差异足以让它们互斥。 - 函数参数方向是逆变的;
strictFunctionTypes豁免方法语法,不豁免属性语法
——这条几乎总被记反,本系列首版就写反了,被实验抓出来(勘误 E-03)。
下一章进入"怎么查询一个类型":keyof / typeof / 索引访问。这是第 04、05 章
的地基——infer 和映射类型都建立在索引访问之上。
本章实验(附录)
实验脚本:[labs/M2-generics-lab.tslabs/M2-generics-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M2-generics-lab.ts"
这个实验做什么:用真实 DSH 源码(packages/util/brand)现场编译若干片段,
把"推断失败退回约束""品牌不可互换""回调方向性"这些论断逐条变成可复现的
编译成功/失败判定。它不看类型文本,只看编译器是否接受——因为这才是你
真正会遇到的信号。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:约束与推断
✓ 显式类型实参把结果定为目标品牌
✓ 不给实参时推断退回约束 Branded<string>
✓ 兜底品牌的标签是宽泛的 string 而非字面量
✓ 该兜底结果无法赋给具体品牌(静默失效被捕获)
── 组 2:品牌不可互换
✓ SessionId 赋给 ToolCallId 报 TS2322
✓ 同一品牌赋给自身通过
✓ 宽泛品牌仍不可赋给具体品牌(差异由字面量保住)
✓ 去掉 extends string 后数值标签也能写出来(约束的真实职责)
✓ 保留 extends string 时同一写法被拒
✓ 裸 string 不能直接赋给品牌,必须经过 brandString
── 组 3:brandString 的运行时形状
✓ brand 包能编译出产物
✓ brandString 只剩一个恒等函数
✓ 品牌在运行时不存在,与第 01 章的擦除结论一致
✓ 注释确实被保留在产物里(所以上一步必须剥注释)
✓ 传 number 在参数位置被拒绝
── 组 4:回调的方向性
✓ 属性语法接受更宽的回调参数(逆变方向成立)
✓ 属性语法拒绝更窄的回调参数(TS2322)
✓ 方法语法接受更窄的回调参数(strictFunctionTypes 豁免方法语法)
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。