TypeScript 精读 01:类型的物理形态

第 01 章 类型的物理形态

代码基线:commit a89bca1316(2026-10-06)| DSH 0.2.0-rc.1 | 本系列讲次:T0

本章导读

这一章要解决一个所有 TS 学习者都会绕过的前提问题:类型到底存不存在?

答案会让人不舒服:不存在。TypeScript 的类型在编译后一个字节都不剩。你写下的
as Local、satisfies Iface、interface Iface、乃至 import type { Nothing },
在产物里全部蒸发,不留痕迹。

这不是"实现细节",这是语言设计的地基。绝大多数 TS 的"反直觉用法"——为什么要
as unknown as T、为什么接口能凭空多出一个属性、为什么类型标注在 as 左边和右边
不等价——都只有在这一层被看穿之后才能理解。

所以本章不打算教你任何"怎么写类型"的技巧,那些是后面九章的事。本章只做三件事:

  1. 让你亲眼看见擦除。我们编译一个包含 as、satisfies、enum、参数属性的片段,
    把产物原文贴出来,让你逐行确认哪些东西消失了。
  2. 指出类型系统唯一不能替你做的事——运行时校验。DSH 的
    [AGENTS.md``AGENTS.md 里有一条极其精确的规则,说清楚了哪些边界必须校验、
    哪些边界校验就是浪费。本章逐行读一个真实的解析函数来讲这件事。
  3. 讲清 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
    ——测试 fixture
  • packages/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 undefined when the string is not a dsh-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  
  }  

两处值得学:

  1. catch 块只有一条语句(return undefined),没有把错误吞掉后继续执行的
    模糊地带。
  2. 空 catch 写清了原因和后果。[AGENTS.md``AGENTS.md 的要求是
    "an empty catch names 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): Spec step in the owning implementation, never a hidden
?? default
inside run() (the dsh-shell request/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  
  ...  
}  

这段代码能跑,但有三个问题,全部源于第一节那张表:

  1. 默认值不在类型里。ShellExecRequest 的类型说 workdir?: string,但真实的
    DEFAULT_WORKDIR 这个值对类型系统完全不可见——它在函数体里,是个纯运行时常量。
  2. 日志记不到。DSH 有一条铁律叫 Model-visible ⟺ logged:凡是模型能看到的
    输入,必须能从会话日志里重建。一个藏在函数体里的默认值,模型看不到、日志里没有,
    于是这条约束被违反了,而且你不会收到任何编译错误。
  3. 测试写不出来。想验证"不传 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。