TypeScript 精读 02:泛型与推断

第 02 章 泛型与推断

代码基线:commit a89bca1316(2026-10-06)| DSH 0.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 行,却把泛型的四个核心问题全部摆在了台面上:

  1. 类型参数与类型实参的区别
  2. 约束(T extends X)买到了什么,又没买到什么
  3. 推断在哪里发生,以及推断失败时会掉到哪里——这是本章最反直觉、也最有用的发现
  4. 回调参数的方向性(逆变),以及 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: a SessionId cannot be passed where a ToolCallId is 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。