TypeScript 精读 06:as const 与 satisfies

第 06 章 as const 与 satisfies

代码基线:commit a89bca1316(2026-10-06)| DSH 0.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 是精装修。

本章有两个反直觉的实测结果,都会推翻"望文生义"的直觉:

  1. satisfies 不制造字面量类型。 它保留"已经推断出来的类型",
    而推断在没有 as const 时本来就是拓宽的。
  2. <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 做三件事:

  1. 字面量收窄:8080 从 number 变成 8080,'x' 从 string 变成 'x'
  2. 深度只读:对象变 readonly,数组变只读元组
  3. 它不改变运行时(第 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。