第 06 章 as const 与 satisfies
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T5
本章导读
这一章讲两个 2022 年之后才有的操作符:as const 与 satisfies。
它们在前面五章里已经反复出现——第 03 章的 keyof typeof SOME_AS_CONST
全靠 as const 才成立,第 05 章的 typeof ARR[number] 同理。
现在把它们讲透,因为它们是那两章的前提,不是附录。
DSH 的用量能说明问题:2970 处 as const、413 处 satisfies。
2970 比 413 多七倍,这个比例本身就是一条信息——as const 是基础设施,
satisfies 是精装修。
本章有两个反直觉的实测结果,都会推翻"望文生义"的直觉:
satisfies不制造字面量类型。 它保留"已经推断出来的类型",
而推断在没有as const时本来就是拓宽的。<const T>类型参数对已经拓宽的值毫无作用。 它只在字面量实参上生效。
读完本章你要能回答:"一个配置常量该写 as const、satisfies,还是 : T?"
三者的取舍有一条清晰的判据。
一、as const:制造窄类型
1.1 它做了什么
const a = { port: 8080, host: 'x' } // a.port: number
const b = { port: 8080, host: 'x' } as const // b.port: 8080
实测:const t1: 8080 = a.port 报错,而 const t2: 8080 = b.port 通过。
as const 做三件事:
- 字面量收窄:
8080从number变成8080,'x'从string变成'x' - 深度只读:对象变
readonly,数组变只读元组 - 它不改变运行时(第 01 章:产物为零)
1.2 深度只读是真的深
const e = { cfg: { port: 8080 } } as const
e.cfg.port = 1 // error TS2540: Cannot assign to 'port' because it is a read-only property
注意不是 e.port,是 e.cfg.port——嵌套一层的属性也读。
普通注解(: T)不会传染只读性,只传染可空性;as const 传染深度只读。
1.3 为什么它是基础设���
回到第 05 章:typeof ARR[number] 能推出字面量联合,前提是 as const。
没有它,数组元素推断成 string,你拿到的就是 string 而不是那个 50 项的联合。
同理 keyof typeof CLOSER_TEXT(第 03 章)
之所以能得到 'interrupted' | 'forked',也是靠 as const。
[packages/core/session/src/repair.ts:41``packages/core/session/src/repair.ts:41
那张模型可见的文案表就是这么用的:
const CLOSER_TEXT = {
interrupted: { started: 'The tool call was interrupted after...', notStarted: '...' },
forked: { started: 'The history inherited by this branch...', notStarted: '...' },
} as const
它下面要按 interrupted / forked 分派,还要取出每一项的两种措辞——
全是 keyof typeof 与索引访问,没有一个字手写。
本章第一条纪律
任何"从值反推类型"的写法都以
as const为前提。
忘了它,你会得到string而不是字面量联合,而且不报任何错。
二、satisfies:校验,但不强制拓宽
2.1 三个写法的实测对照
同一份配置,三种标注方式。实测结果:
| 写法 | 校验错误值 | 保住字面量 8080 |
产物 |
|---|---|---|---|
| 无标注 | ✗ 不校验 | ✗ | 有代码 |
as const |
✗ 不校验 | ✓ | 无 |
: Config |
✓ | ✗ | 有代码 |
satisfies Config |
✓ | ✗ | 无 |
as const satisfies Config |
✓ | ✓ | 无 |
第二行与第四行的对照是 satisfies 的全部卖点:它比 : Config 多做一件事
——不拓宽。实测 const c: Config = {...}; c.port 的类型是 number,
而 satisfies 版本在配合 as const 后仍然是 8080。
2.2 第一个反直觉实测:satisfies 自己不制造字面量
const d = { port: 8080, host: 'x' } satisfies Config
const t4: 8080 = d.port // ✗ 报错:Type 'number' is not assignable to type '8080'
这一行报错。 很多人第一次遇到会以为 satisfies 坏了。
原因在于它保留的是"推断出来的类型",而对象字面量的自然推断本来就是拓宽的。
satisfies 的职责是"别再往下拓宽",不是"往上收窄"。
本章第二条纪律
satisfies= 校验 + 停止拓宽。它不会把number变成8080。
要字面量,必须显式写as const。二者可以叠加——
as const satisfies Config才是完整的形态。
2.3 组合起来才是完整形态
const d = { port: 8080, host: 'x' } as const satisfies Config
const t1: 8080 = d.port // ✓ 通过:字面量保住了
d.port = 1 // ✗ TS2540:只读
const bad = { port: '8080', host: 'x' } as const satisfies Config // ✗ TS2322:校验仍生效
三行同时成立:窄、只读、校验。DSH 里 satisfies 的用法集中在
.../src/types.ts 这类契约文件上(} satisfies R、} satisfies P
等 84 / 62 / 21 处),配合 as const 的写法正是这个模式。
本章第三条纪律
写配置常量、契约对象、字面量表:
as const satisfies T。
只有需要"函数返回的对象由调用方自行决定形状"时才单独用satisfies。
三、<const T>:类型参数上的 as const
TS 5.0 加了一个新东西:把 const 写进类型参数列表。
function f<const T>(v: T) { return v }
function g<T>(v: T) { return v }
const r1 = g({ a: 1, list: [1, 2] }) // { a: number; list: number[] }
const r2 = f({ a: 1, list: [1, 2] }) // { readonly a: 1; readonly list: readonly [1, 2] }
实测确认:给 r2 标注 { a: 1 } 会报 readonly 不兼容,
r2.list.push(3) 报 TS2339: Property 'push' does not exist on type 'readonly [1, 2]'。
它就是"把 as const 的力量延伸到函数入参"。 对已经拓宽的值没用:
declare const fromVar: string
function withConst<const C extends string>(code: C) { ... }
withConst(fromVar).properties.code.const // 仍然是 string,不是字面量
实测:加不加 const,传 string 变量都推不出字面量。
const 修饰符只作用于字面量实参(对象字面量、数组字面量),
它让编译器在推断时选择最窄的那个候选。
3.1 DSH 的真实用例
[packages/schedule/schedule/src/tools.ts:97``packages/schedule/schedule/src/tools.ts:97:
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
return {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true, const: code },
message: { type: 'string', required: true },
},
} as const
}
JSDoc 那句 "while preserving its literal code" 就是 <const C> 的存在理由:
返回值里的 const: code 是一个 JSON Schema 约束,要求 code 字段等于
字面量 'NOT_FOUND' 之类。如果 C 被推断成 string,这个 schema 就废了——
它会接受任意字符串,与 const 这个 JSON Schema 关键字的语义直接矛盾。
同类写法在 DSH 里还有
packages/experimental/tool-agent-team/src/index.ts:146 的
jsonOutput<const S extends ValueSchemaSpec>(schema: S)。
本章第四条纪律
泛型函数要"原样保留字面量实参的类型"时,在类型参数上加
const,
而不是让调用方自己as const。这属于签名级决策——
它把调用方的负担移到了函数作者身上。
四、readonly 的两个来源
第 01 章说过 Branded<B> 是 string & { readonly [BRAND]: B }——
那里的 readonly 是为了保护那个假想的键不被赋值,让交叉里的字符串部分
保持完全可写。
as const 带来的 readonly 语义不同:它把整个对象冻住。
两者的选择标准:
| 需求 | 写法 |
|---|---|
| 只保护某个"其实不存在"的键,值本身照常可变 | readonly 修饰那一个键 |
| 整个对象当常量用,不接受任何修改 | as const |
本章第五条纪律
readonly修饰单个属性 ≠as const。
前者防"伪造",后者防"手滑"。
五、本章小结
as const制造窄类型:字面量收窄 + 深度只读 + 产物为零。
它是第 03、05 章所有"从值反推类型"的前提。satisfies校验并停止拓宽,但不制造字面量。
它的价值是相对: T而言的:: T会把字面量信息丢掉,satisfies不会。- 完整形态是
as const satisfies T:窄 + 只读 + 校验,三者兼得。 <const T>类型参数把as const的力量延伸到泛型入参,
只对字面量实参生效,对已拓宽的值无效。readonly单属性 ≠as const,前者防伪造,后者防手滑。
下一章进入本系列最大的差异化资产:declare module 与声明合并。
第 05 章末尾已经埋好伏笔——SessionEventMap 散落在 32 个文件里,
靠合并汇成一体。那条链路的机制就是下一章的全部内容。
本章实验(附录)
实验脚本:[labs/M6-const-lab.tslabs/M6-const-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M6-const-lab.ts"
这个实验做什么:第一节到第五节的每一条断言都来自实测,其中两条
(satisfies 不制造字面量、<const T> 对拓宽值无效)是首版写错、
被实验推翻后改正的。实验保留这两条作为回归防线。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:as const 的三件事
✓ 字面量收窄:8080 从 number 变 8080
✓ 深度只读:嵌套一层的属性也拒绝赋值
✓ 产物为零:CLOSER_TEXT 编译后是普通对象字面量,as const 无残留
── 组 2:四种标注方式的对照
✓ : T 校验通过但丢掉字面量
✓ satisfies 校验通过
✓ satisfies 本身不制造字面量(首版写错,保留为回归防线)
✓ as const satisfies 同时拿到三者
✓ satisfies 对错误值仍然报错
── 组 3:const 类型参数
✓ const 让字面量实参推断成深度只读窄类型
✓ 非 const 版本推断成拓宽类型
✓ const 版数组是只读元组,push 不存在
✓ const 对已经拓宽的值无效(首版写错,保留为回归防线)
── 组 4:DSH 的真实用例
✓ basicErrorSchema 的 const C 保住字面量 code
✓ CLOSER_TEXT 的 as const 让 keyof 推出字面量联合
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。