第 01 章 类型的物理形态
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T0
本章导读
这一章要解决一个所有 TS 学习者都会绕过的前提问题:类型到底存不存在?
答案会让人不舒服:不存在。TypeScript 的类型在编译后一个字节都不剩。你写下的
as Local、satisfies Iface、interface Iface、乃至 import type { Nothing },
在产物里全部蒸发,不留痕迹。
这不是"实现细节",这是语言设计的地基。绝大多数 TS 的"反直觉用法"——为什么要
as unknown as T、为什么接口能凭空多出一个属性、为什么类型标注在 as 左边和右边
不等价——都只有在这一层被看穿之后才能理解。
所以本章不打算教你任何"怎么写类型"的技巧,那些是后面九章的事。本章只做三件事:
- 让你亲眼看见擦除。我们编译一个包含
as、satisfies、enum、参数属性的片段,
把产物原文贴出来,让你逐行确认哪些东西消失了。 - 指出类型系统唯一不能替你做的事——运行时校验。DSH 的
[AGENTS.md``AGENTS.md里有一条极其精确的规则,说清楚了哪些边界必须校验、
哪些边界校验就是浪费。本章逐行读一个真实的解析函数来讲这件事。 - 讲清 DSH 一条设计纪律的物理基础:为什么默认值必须是显式的一步,而不是运行时
偷偷兜底。
读完本章你要能回答:"我给它标了类型,它就安全了吗?" 答案是"取决于那个值从哪来"。
一、先证伪:类型在产物里不存在
我们先不看任何文档,直接编译。
准备一个文件,它包含了本节要考察的全部构造:
// t.ts
import type { Nothing } from './types'
type Local = { a: number }
interface Iface { b: string }
const x = { a: 1 } as Local
const y = { b: 'q' } satisfies Iface
const z = 'raw' as unknown as number
enum E { A = 'a', B = 'b' }
class C { constructor(private readonly n: number) {} get() { return this.n } }
export { x, y, z, E, C }
用仓库自己的 tsc 编译,产物 t.js 全文如下:
const x = { a: 1 };
const y = { b: 'q' };
const z = 'raw';
var E;
(function (E) {
E["A"] = "a";
E["B"] = "b";
})(E || (E = {}));
class C {
n;
constructor(n) {
this.n = n;
}
get() { return this.n; }
}
export { x, y, z, E, C };
逐项对照:
| 你写的 | 产物里剩下什么 |
|---|---|
import type { Nothing } |
无。连 import 语句都没了 |
type Local |
无 |
interface Iface |
无 |
as Local |
无。{ a: 1 } as Local 变成 { a: 1 } |
satisfies Iface |
无。{ b: 'q' } satisfies Iface 变成 { b: 'q' } |
as unknown as number |
无。'raw' 还是 'raw' |
enum E |
有。一个 var + 一个立即执行函数 |
class C 的参数属性 |
有。this.n = n 真的执行了 |
这张表就是本章的全部。前六行是本章的主题,后两行是本章的例外清单。
1.1 那句 as unknown as number
请特别注意产物第三行:
const z = 'raw';
你在源码里对编译器说"这是 number"。编译器信了,于是放行。然后这个值在运行时依然
是一个字符串。如果后面有人写 z.toFixed(2),得到的是 TypeError: z.toFixed is not a function——一个只有在生产环境、只在某条罕见路径上才炸的错。
这不是 TypeScript 的 bug。TypeScript 从头到尾只承诺一件事:帮你发现"你确信错了"
的地方。它从不承诺"帮你发现你确信对了但其实错了的地方"。as 是你递给编译器的一张
欠条,编译器选择相信你,仅此而已。
本章第一条纪律
as不是转换,是断言。断言的意思是"我检查过了,我是对的"。
如果你其实没检查过,那你写的是谎言,而编译器不会拆穿你。
二、三个空间
要解释第一章那张表,需要引入 TS 的空间模型。TS 里同时存在三个名字空间,它们
彼此独立:
值空间——运行时真实存在的东西。变量、函数、类、enum。产物里能看见的就是它。
类型空间——编译期存在的类型别名、接口、类型参数。产物里看不见。
声明空间——值 + 类型的合并空间。class 同时在值空间和类型空间存在;interface
只在类型空间存在;const enum 这种历史遗留形态会同时占据两者。
import type 这个写法的全部意义,就是显式声明"我只要类型空间里的那个名字"。
有了它,编译器在产物里就敢把整个 import 语句删掉,连运行时解析的开销都不留。
[tsconfig.base.json``tsconfig.base.json 里 verbatimModuleSyntax 设为
false,意味着你不写 type 也能被自动擦除——但那就依赖编译器的推断,而不是你的
声明。第 07 章会讲这种"靠推断"的代价。
一个直接的推论,也是面试和 code review 里最常考的:
interface Foo { a: number }
declare const Foo: unique symbol // Foo 现在同时在两个空间
interface Bar { a: number }
const Bar = 1 // Bar 现在也同时在两个空间
两行代码,产物完全相同(const Bar = 1;),但 Foo 和 Bar 都可以被 typeof 取到
——因为它们确实在值空间里真实存在。这一点在第 08 章讲品牌类型时会再次用到。
三、as 是一句谎,而且是允许你说的谎
上面已经展示了 as unknown as number 的危险。补上另一半:as 的方向性。
Expr as T 的含义不是"把 Expr 变成 T",而是"在 Expr 与 T 存在合法重叠的前提下,
把 Expr 当成 T 看"。合法重叠指两者在结构上可比较——{a: number} 与 {b: string}
互不重叠,直接 as 会报错;而 string 与 number 也不重叠,所以你需要
as unknown as number:先把它降级到最宽的 unknown(与一切都重叠),再升上去。
这也解释了为什么 DSH 的 [AGENTS.md``AGENTS.md 有一条硬规则:
No new assertions to
unknown(as unknown或<unknown>)。
保持或减少既有的精确基线;替代它们要用类型或校验。
这条规则不是洁癖。as unknown 是类型系统里唯一能完全绕过所有检查的逃生舱——
它把"编译器能不能证明"这个问题从"能"降级成"我不参与判断"。在 DSH 这种
328 个包、跨进程、跨插件边界的代码库里,逃生舱一旦滥用,所有跨包的类型保证就全部
失效,而且是静默失效。
本章第二条纪律
as用来收窄(你确知一个更宽的东西此刻是更窄的),不用来拓宽。
需要拓宽时,正确的做法是在边界上做运行时校验,然后用校验后的产物,而不是用as假装。
第 08 章会给出"正确做法"的具体模板。
四、satisfies:校验留下,拓宽不留
as 只能单向表达"我确信",而且它不检查。satisfies 补的正是这一块:
const y = { b: 'q' } satisfies Iface
- 校验:
b必须是string。写错就报错。 - 不拓宽:结果的类型仍然是
{ b: string }的字面量推断,不是被压成Iface。 - 产物为空:和
as一样,编译后什么都没留下。
第 06 章会专门讲透它与 as const 的组合,那是 DSH 里出现 2970 次的构造。但现在
只需要记住它在这张对照表里的位置:它和 as 一样是纯编译期的,产物为零。
五、活下来的东西:只有两类
回到那张表的后两行。真正在产物里生成代码的 TypeScript 构造,实用范围内只有两个:
5.1 enum
产物是一个 var 加一个立即执行函数。注意这个立即执行函数的用途:建立反向映射。
E.A = 'a' 建立正向,E['a'] = E.A 建立反向——所以 E.A 运行时确实是一个对象,
占用真实内存,有真实行为。
DSH 全仓的 enum 声明数量是 2 个:
packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts
——测试 fixturepackages/session/session-telemetry-otel/src/index.ts——生产代码里的唯一一处
一个 328 个包的仓库只有 1 处生产 enum,这不是巧合。替代方案是
as const 对象 + 派生联合类型:
const ShellExpiryPolicy = { kill: 'kill', none: 'none' } as const
type ShellExpiryPolicy = typeof ShellExpiryPolicy[keyof typeof ShellExpiryPolicy]
这一行产生的联合类型与 enum 完全等价,但产物为空。第 05、06 章会把这两行的推导
过程完整走一遍。DSH 的
[packages/shell/shell/src/types.ts``packages/shell/shell/src/types.ts 里
ShellExpiryPolicy 用的是纯联合类型字面量:
export type ShellExpiryPolicy = 'kill' | 'none'
本章第三条纪律
优先写
'a' | 'b',其次as const对象,最后才考虑enum。
前两者产物为零,enum会在运行时生成真实对象。
5.2 class 与参数属性
class 有运行时表示(ES2022 起 class 字段是标准语法),这部分不算"TS 特有"。真正
值得说的是参数属性:
class C { constructor(private readonly n: number) {} get() { return this.n } }
产物里多出了 this.n = n; 这一行赋值。也就是说构造函数的这段代码是 TypeScript
帮你生成的——如果换成等价的手写版本:
class C { n; constructor(n) { this.n = n } get() { return this.n } }
两者产物相同。参数属性是语法糖,不是类型构造。
DSH 里有 270 处参数属性。它们的用途是让"服务类"能同时满足"值空间需要 new"和
"类型空间需要当类型标注"这两个要求——这正是第 02 节说的双空间存在。
六、类型系统唯一不能替你做的事:运行时校验
前面五节都在说类型是编译期的。既然如此,编译器能为你做的事就有了清晰的边界。
DSH 在 [AGENTS.md``AGENTS.md 里把这条边界写得比大多数规范都精确:
Trust TypeScript at typed same-process boundaries. Do not add runtime validation,
fallback behavior, or hostile-input tests solely for values the static interface requires;
validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and
wire boundaries.
翻译过来:同进程、类型已知的边界,信类型;值来自语言之外的地方,必须校验。
后者被列举得很具体:解析器与配置、队列、模型与工具的 JSON、持久化与文件、worker、
进程、线路(wire)。
这不是教条,是第一节那张表的直接推论。类型只在编译期存在,所以任何在编译期之外
进入的值,类型对它一无所知。典型地:
const data = JSON.parse(text) // 任何
use(data.items) // 编译器相信你,但 data.items 可能是 undefined
JSON.parse 的返回类型是 any,那是语言层面的诚实。危险不在 JSON.parse 本身,
危险在你把 any 传给了一个期望具体类型的函数,而编译器放行了。
6.1 逐行读一个真实的解析函数
我们看 DSH 里一个教科书式的例子:
[packages/util/workspace-path/src/file-address.ts``packages/util/workspace-path/src/file-address.ts:89
的 parseFileAddress。
签名(第 89 行):
export function parseFileAddress(address: string): FileAddress | undefined {
参数是 string,不是 FileAddress。 这是整个设计的要点:函数接受的是
不可信的字符串,产出的是已经被验证过的类型。类型安全发生在函数内部,
而不是靠调用方的自觉。
JSDoc 第 87 行把失败条件写得很完整:
the parts, or
undefinedwhen the string is not adsh-resource://file/URI in a
known scope with a path, or a segment is not validly encoded.
三种失败,逐一在代码里兑现:
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined // ① 前缀不对
...
if (scope === 'session') {
const [id, ...segments] = rest
if (id === undefined || id === '' || segments.length === 0) return undefined // ② 结构不对
return { scope, sessionId: decodeURIComponent(id), path: ... }
}
if (scope === 'absolute') {
const unc = rest[0] === '' && rest.length > 1
const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent)
if (segments.length === 0 || segments[0] === '') return undefined // ③ 空路径
...
}
return undefined // ④ 未知 scope
这四个 return undefined,没有一条是编译器能替你做的。它们是在运行时对一段
真实的、可能来自用户输入或磁盘的字符串做出的判断。把它们删掉,编译器不会有任何反应。
6.2 一处容易被读错的地方
const unc = rest[0] === '' && rest.length > 1
rest 来自数组解构,理论上可以为空,所以 rest[0] 运行时可能是 undefined。
这行代码安全吗?是的,但不是因为编译器保证的——
而是因为
[tsconfig.base.json``tsconfig.base.json 里开着
"noUncheckedIndexedAccess": true。这个选项让 rest[0] 的类型变成
string | undefined 而不是 string,于是 rest[0] === '' 成为一个真实的窄化检查,
而下面第 103 行的 segments[0] === '' 之后,编译器才知道 segments[0] 非空。
对比一下:如果这个选项是关的,rest[0] 的类型是 string,=== '' 只是一个普通比较,
而 segments[0] 之后直接用也没有任何提示。一个配置项,把"看起来能跑"变成了"证明
不会错"。
这正是严格配置的价值——它不阻止你写代码,它让该报错的地方报错。
6.3 一个空 catch 的写法
第 108–110 行:
} catch {
// `decodeURIComponent` throws URIError on a malformed escape.
return undefined
}
两处值得学:
catch块只有一条语句(return undefined),没有把错误吞掉后继续执行的
模糊地带。- 空
catch写清了原因和后果。[AGENTS.md``AGENTS.md的要求是
"an emptycatchnames the error and why"。这里点名了decodeURIComponent和
URIError,还给出了畸形转义这个具体触发条件。
对比一个不合格的写法:
} catch (e) {
// ignore
}
三个月后没人知道这里吞掉了什么。
七、把默认值从"运行时兜底"挪到"显式的一步"
现在我们能看懂 DSH 一条设计纪律的物理基础了。
[AGENTS.md``AGENTS.md:
Explicit > implicit at package boundaries: defaulting is an explicit
resolve(request): Specstep in the owning implementation, never a hidden
?? default
insiderun()(thedsh-shellrequest/spec split is the template).
规则本身是"不要在 run() 里偷偷 ?? default"。为什么? 因为擦除。
看
[packages/shell/shell/src/types.ts``packages/shell/shell/src/types.ts:56
起的 ShellExecRequest:
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link ShellExecutor.resolve} from the implementation's config.
* This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a
* fully-resolved {@link ShellExecSpec}.
*/
export interface ShellExecRequest {
command: string
workdir?: string | undefined
timeoutMs?: number | undefined
onExpiry?: ShellExpiryPolicy | undefined
...
}
ShellExecRequest 里有三个可选字段。假设实现方在 run() 里直接兜底:
async function run(request: ShellExecRequest) {
const workdir = request.workdir ?? DEFAULT_WORKDIR
...
}
这段代码能跑,但有三个问题,全部源于第一节那张表:
- 默认值不在类型里。
ShellExecRequest的类型说workdir?: string,但真实的
DEFAULT_WORKDIR这个值对类型系统完全不可见——它在函数体里,是个纯运行时常量。 - 日志记不到。DSH 有一条铁律叫 Model-visible ⟺ logged:凡是模型能看到的
输入,必须能从会话日志里重建。一个藏在函数体里的默认值,模型看不到、日志里没有,
于是这条约束被违反了,而且你不会收到任何编译错误。 - 测试写不出来。想验证"不传 workdir 时会怎样",你只能真的去看文件系统,
而不是断言resolve({})的结果。
resolve(request): Spec 的拆分同时解决三者:
resolve()是显式的一步,调用方能看见它在发生;- 产出的
ShellExecSpec每一个字段都是必填,类型里就写明了真实值从哪来; resolve({})是一个纯函数,可以脱离一切外部依赖单测。
本章第四条纪律
任何在运行时补上的值,都应该有一次显式的、类型可见的、可单测的产生过程。
?? default把这件事藏进了函数体,等于把一个真实的决定伪装成实现细节。
八、三个"类型说了不算数"的地方
综合本章,列出三个必须自己操心的场景:
1. 任何 JSON.parse 的结果。 返回 any,编译器从此不再问你任何问题。
2. 任何跨进程传递的值。 子进程、worker、wire(WebSocket / HTTP)。类型在跨过
进程边界的那一刻归零,接收方必须重新校验。DSH 把 worker、process、wire 三个词
明确写进校验清单,就是这个意思。
3. 任何文件系统或持久化层读回来的东西。 磁盘上的文件可能被另一个程序、另一个
版本、或者一次手改动过。AGENTS.md 的清单里 durable/file 排在很前面。
这三处的共同点是:它们都在编译期的边界之外。只要还在同一个进程、类型是静态
可知的,编译器就替你看着;一旦越过边界,所有的保证都归零,必须由运行时校验重建。
而第 06 节的 parseFileAddress 就是"重建"的标准形态:接受最宽的类型,返回最窄
的类型,用一个 | undefined 把失败显式化。
九、本章小结
- 类型在编译后一个字节都不剩。产物为零的构造包括
type、interface、
import type、as、satisfies。 - 真正生成运行时代码的只有
enum(含反向映射)和 class 的参数属性。
DSH 全仓只有 1 处生产enum,正说明这是刻意选择。 as是断言不是转换。它的方向是"我确信",不是"帮我转"。
as unknown是绕过所有检查的逃生舱,DSH 明令禁止新增。- 类型系统唯一不能替你做的是运行时校验。判据是"这个值从哪来":
语言之外来的(parser/config、queue、JSON、durable、worker、process、wire)必须校验,
同进程静态已知的必须信任。 - 把默认值放进显式的
resolve(request): Spec,是因为藏在函数体里的默认值
对类型系统不可见、模型不可见、日志不可见、测试不可见。
下一章进入"怎么写类型"。起点是最基础也最被跳过的那个:泛型与推断。DSH 全仓有
142 处 infer,但它从不解释泛型是什么——这个缺口由第 02 章补。
本章实验(附录)
实验脚本:[labs/M1-erase-lab.tslabs/M1-erase-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M1-erase-lab.ts"
这个实验做什么:现场编译一个包含 as / satisfies / import type / enum /
参数属性的片段,然后逐条断言产物里"什么还在、什么没了"。它不依赖本章的结论,
而是每次运行都重新编译、重新验证。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:产物中不该存在的
✓ type/interface 声明被完全擦除
✓ import type 语句被完全擦除
✓ as 断言不产生任何运行时代码
✓ satisfies 校验不产生任何运行时代码
✓ as unknown as number 的值在运行时仍是字符串
── 组 2:产物中确实存在的
✓ enum 生成 var 与反向映射 IIFE
✓ enum 建立了反向映射
✓ class 参数属性生成真实的赋值
── 组 3:配置项如何改变检查
✓ 关闭 noUncheckedIndexedAccess 时 rest[0] 被当作 string,不报错
✓ 开启 noUncheckedIndexedAccess 后同一行报 TS2532
── 组 4:运行时校验不可省略
✓ ① 前缀不匹配 → undefined
✓ ② session 作用域缺少路径段 → undefined
✓ ③ 空路径段 → undefined
✓ ④ 未知作用域 → undefined
✓ 畸形百分号转义由 URIError 分支兜住
✓ 合法 session 地址被接受
✓ 合法 absolute 地址被接受
✓ 类型标注不阻止任何一条失败分支:五类坏输入全部返回 undefined
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。