TS·00 入门与心智模型:TS = JS + 会被擦除的类型层

这是「读懂 pi 的 TypeScript」系列的开篇。整个系列的目标只有一个:让一个熟练的 Python 程序员在几天内既学会 TypeScript 这门语言,又能看懂 pi —— 一个真实的 AI coding-agent 框架(TypeScript monorepo,含 LLM 客户端、agent 运行时、CLI、多 agent 编排、自研终端 UI 五个包)。我们不写玩具代码,每一节的「代码引用」都逐字来自 pi 仓库。

本系列有个明确取向:不脱离真实代码讲抽象语法。语言特性(泛型、联合、窄化……)按依赖顺序逐篇引入,但每一条都落到 pi 里的一处真实用法上,让你「学到即见到」。对 Python 出身的你,重点不在「编程」而在「TS 的语法长相 + 类型系统的思维」,所以每节都配一个 Python 对照,帮你把已有直觉平移过来。

九篇的路线图:本篇先建立心智模型并把仓库跑起来;然后值、类型、函数 → interface 与 type → 联合与可辨识联合 → 窄化与类型守卫 → 泛型 → 类与模块 → 异步与流式 → 最后实战总结。读完 T0,你会理解 TypeScript 到底是什么、pi 的 tsconfig 为什么长那样、怎样用 tsx 直接跑一个 .ts 文件、以及 pi 满仓库带 .ts 后缀的 import 是怎么回事。

1. TS 是什么:一层会被擦除的类型

1.1 直觉

TypeScript 不是一门独立的新语言,而是 JavaScript 的超集:任何合法的 JS 都是合法的 TS,TS 只是在 JS 之上加了一层编译期的类型标注。关键心智模型是:这层类型在编译后会被完全擦除,产物是纯 JavaScript,运行时不留任何类型痕迹。

为什么要这层类型?因为 JavaScript 是动态类型语言,"5" - 1 会静默算出 4、拼错的字段名要等到运行时才炸。TypeScript 把这些错误提前到编写代码的那一刻:编辑器里就红线报错,编译不通过就不给产出。代价是你要多写类型标注、多过编译这一关;收益是重构大型代码库时编译器帮你兜底。pi 这种几万行、五个包相互依赖的项目,正是靠这层类型才敢放心改动。

Python 对照:TS 的类型标注看起来像 Python 的 type hints(x: int),但有两点根本不同。其一,Python 默认不检查 hints,要靠 mypy 这类外部工具;TS 的 tsc 编译时默认就检查,类型不匹配直接报错、拒绝出活。其二,Python 的 hints 在运行时仍可通过 __annotations__ 读到;TS 的类型在运行时根本不存在,连 typeof MyType 都写不出来 —— 类型和值活在两个不重叠的世界里,这个「双世界」直觉贯穿整个系列,务必先立住。

1.2 最小 demo

// 教学示例 — 非生产代码
// 冒号后面是类型标注,编译后会被擦掉
function greet(name: string): string {
  return "hello, " + name;
}

const msg: string = greet("pi");
// greet(42);  // ← 编译期报错:number 不能传给 string 形参

// 上面这段编译成 JS 后,类型全部消失,只剩:
//   function greet(name) { return "hello, " + name; }
//   const msg = greet("pi");

: string 是类型标注,function、const、字符串拼接都是原样保留的 JS。编译器做的事就是:先按标注检查一遍,然后把所有标注删掉。

1.3 正式化

一处类型标注的写法是「在被标注的东西后面加 : 类型」:变量 const x: number、函数形参 (name: string)、函数返回值 function f(): string。这些冒号语法只是给编译器看的元信息,不产生任何运行时代码。

TypeScript 有两种编译/运行路径,对本系列都重要:

pi 在 tsconfig 里开了一个关键开关 erasableSyntaxOnly。它的含义是:只允许"能被直接擦除"的类型语法,禁用那些编译后会生成额外运行时代码的 TS 专有构造 —— 例如 enum、namespace(带运行时体的)、构造函数参数属性、以及会注入运行时元数据的旧式 decorator。为什么禁?因为像 enum Color { Red, Green } 这种写法,编译后会真的生成一个 JS 对象,它不是「可删除的标注」,而是掺进运行时的代码 —— 这就破坏了「类型层可整体擦除」的干净模型。结果是全仓库看不到 enum,一律改用字符串字面量联合(如 "read" | "write",详见联合)。这样做的直接好处是:.ts 文件在语义上严格等价于「JS + 一堆可删除的标注」,于是任何只做语法擦除、不做类型分析的快速工具(esbuild、甚至 Node 22 内建的 type-stripping)都能正确处理它,构建更快、心智更简单。这也是为什么本系列你不会见到 enum —— 不是没讲,是 pi 里根本没有。

1.4 代码引用

pi/tsconfig.base.json:L1-L24 — 全仓库共享的编译器基线配置

{
	"compilerOptions": {
		"target": "ES2022",
		"module": "Node16",
		"lib": ["ES2022"],
		"strict": true,
		"erasableSyntaxOnly": true,
		"esModuleInterop": true,
		"skipLibCheck": true,
		// ...
		"moduleResolution": "Node16",
		"resolveJsonModule": true,
		"allowImportingTsExtensions": true,
		"rewriteRelativeImportExtensions": true,
		// ...
		"useDefineForClassFields": false,
		"types": ["node"]
	}
}

对照本节讲的点:"target": "ES2022" 说明擦除后产物是相当现代的 JS;"strict": true 打开全套严格检查(包括 null/undefined 必须显式处理,后续各篇都建立在它之上);"erasableSyntaxOnly": true 就是上面说的「只留可擦除语法」的总开关,它直接决定了整个 pi 代码库的写法风格。allowImportingTsExtensions 与 rewriteRelativeImportExtensions 是第 3 章要讲的 .ts 后缀导入的来源,先记个名字。

1.5 洞察

2. 工具链:怎么把 pi 跑起来读代码

2.1 直觉

要读懂一个仓库,先得能把它跑起来。pi 是个 monorepo(单仓多包):一个 git 仓库里并排放着 5 个相互依赖的 npm 包(ai、agent、coding-agent、orchestrator、tui)。管理它们的机制叫 npm workspaces —— 在仓库根 npm install 一次,npm 就会把所有子包的依赖装好,并让子包之间用包名(如 @earendil-works/pi-ai)直接互相引用,而不必真的发布到公共 npm registry。这就是为什么你能 import { ... } from "@earendil-works/pi-ai" 引到隔壁包 —— npm 在本地把包名软链到了对应目录。

Python 对照:monorepo + workspaces 大致相当于一个仓库里放多个可安装包、用 pip install -e(editable install)让它们互相引用的组合;package.json 则同时扮演 pyproject.toml(元数据 + 依赖)和 Makefile(scripts 里的命令快捷方式)两个角色。

2.2 最小 demo

// 教学示例 — 非生产代码
// 假设一个最小 monorepo 根 package.json 的骨架:
//
//   {
//     "type": "module",              // 全仓用 ESM(见第 3 章)
//     "workspaces": ["packages/*"],  // packages 下每个目录是一个子包
//     "scripts": { "check": "tsgo --noEmit" },
//     "engines": { "node": ">=22" }
//   }
//
// 在仓库根执行(注释示意,非代码):
//   npm install     // 一次装好所有子包依赖
//   npx tsx packages/agent/src/some-script.ts  // 直接跑 .ts,不用先编译
//   npm run check   // 运行 scripts.check

tsx 是这里的主角:它内部用 esbuild 即时把 .ts 擦成 JS 再交给 Node 执行,所以你读代码时想验证某个文件的行为,直接 npx tsx 那个文件.ts 就行,省掉「先 build 再 node」两步。

2.3 正式化

跑起来 pi 需要几件事,都编码在根 package.json 里:

biome 是这里的 lint/format 一体化工具(用 Rust 写,替代传统的 ESLint + Prettier 组合);npm run check 里的 biome check --write 会顺手把代码格式化并报出问题。这些工具都进 devDependencies 而非运行时依赖,因为它们只服务开发流程、不进最终产物。

顺带厘清几个初见易混的名字:npm 是 Node 的包管理器兼命令入口(相当于 pip + 一部分 make);npx 是「不全局安装、临时执行某个包的可执行文件」(如 npx tsx foo.ts);tsx 和 jiti 都是「直接加载并运行 .ts」的运行器,区别只是实现细节。对读代码的你来说,记住一条就够:想跑某个 .ts,npx tsx 路径.ts。

2.4 代码引用

pi/package.json:L1-L16, L41-L55 — 根 package.json:workspaces、scripts、engines、开发工具

{
	"name": "pi-monorepo",
	"private": true,
	"type": "module",
	"workspaces": [
		"packages/*",
		"packages/coding-agent/examples/extensions/with-deps",
		// ...
	],
	"scripts": {
		"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../orchestrator && npm run build",
		// ...
	},
	"devDependencies": {
		"@biomejs/biome": "2.3.5",
		"@types/node": "22.19.19",
		"jiti": "2.7.0",
		"tsx": "4.22.1",
		"typescript": "5.9.3"
	},
	"engines": {
		"node": ">=22.19.0"
	}
}

对照本节讲的点:"type": "module"(第 3 章的 ESM 开关)、"workspaces"(monorepo 子包清单)、"scripts" 里 build 逐包编译、check 串起 biome + 一堆自检 + tsgo --noEmit(只做类型检查、不输出文件)。devDependencies 里同时出现 tsx 和 jiti 两个 .ts 运行器,engines.node 钉死 >=22.19.0。这些字段就是「把 pi 跑起来读代码」的全部入口。

2.5 洞察

3. 模块系统:ESM、具名导出、barrel、.ts 扩展名导入

3.1 直觉

JavaScript 现代的模块系统叫 ESM(ECMAScript Modules),用 import / export 关键字。之所以要强调「现代」,是因为 JS 历史上还有一套老的 CommonJS(require() / module.exports,即 Node 早年那套)。pi 在根 package.json 里写了 "type": "module",一句话把全仓库所有 .ts/.js 都按 ESM 解释,跟 CommonJS 彻底切割。ESM 的默认风格是具名导出:一个文件 export 出若干个带名字的东西,别处按名字 import { 名字 } 取用 —— 名字必须对得上,导入侧写错名字编译期就报错。

Python 对照:ESM 的 import { foo } from "./bar.ts" 约等于 Python 的 from .bar import foo;export * from "./x.ts" 则约等于在 __init__.py 里 from .x import * 做 re-export。差别在于 JS 的具名导出是静态的 —— 导出了什么在编译期就确定,打包器可据此做 tree-shaking(删掉没被 import 的代码)。

3.2 最小 demo

// 教学示例 — 非生产代码

// 文件 math.ts —— 具名导出两个东西
export function add(a: number, b: number): number {
  return a + b;
}
export type Pair = { x: number; y: number };

// 文件 index.ts —— "barrel":把子模块整体转发出去
export * from "./math.ts";   // 注意 import 路径带 .ts 后缀!

// 文件 main.ts —— 从 barrel 按名字取用
import { add, type Pair } from "./index.ts";
const p: Pair = { x: 1, y: 2 };
const s: number = add(p.x, p.y);

这里三个要点都出现了:具名导出(export function / export type)、barrel(index.ts 用 export * from 把整个子模块转发)、以及 import 路径带 .ts 后缀。

3.3 正式化

具名导出 vs 默认导出:ESM 有两类导出。具名导出可以有任意多个,每个带名字;默认导出每个文件至多一个,用 export default X,导入侧 import 任意名 from "..."(无花括号)。pi 几乎只用具名导出(export function foo、export const bar、export interface Baz),取用时 import { foo, bar } from "...",花括号里的名字必须和导出名一致。统一用具名导出的好处是:符号名在导入导出两侧一致,全局搜一个名字就能找齐所有引用,重构友好 —— 这对读大型代码库尤其重要。

barrel(桶文件):一个 index.ts,内容基本全是 export * from "./子模块.ts" 或 export { A, B } from "./子模块.ts"。它把一个目录下分散的多个文件收拢成一个统一入口,外部只需 import { 任意符号 } from "包名" 而不用记具体文件路径。这正是 __init__.py 里堆 re-export 的那套用法。

export type —— 纯类型再导出:export type { Foo } from "./x.ts" 明确标注「这是一个只在类型层存在的导出」。因为类型会被擦除(第 1 章),这行在编译后完全消失,不产生任何运行时 import。它给编译器和打包器一个明确信号:此符号无运行时副作用,尽管删。

.ts 扩展名导入:标准 ESM 要求 import 路径写全扩展名,但通常写的是编译后的 .js(即使源文件是 .ts)。pi 反其道而行,直接在源码里 import "./x.ts" —— 这需要 tsconfig 里 allowImportingTsExtensions(允许源码写 .ts 后缀)配合 rewriteRelativeImportExtensions(编译时自动把 .ts 改写回 .js)。好处是源码里的路径和真实文件名一一对应,点开即达,不用在脑内做 .ts↔.js 转换。这在 TS 生态里不算常见,看到时要意识到:它是配置换来的,不是默认行为。

3.4 代码引用

pi/packages/agent/src/index.ts:L1-L5, L44-L46 — agent 包的 barrel 入口:export * 转发 + .ts 后缀

// Core Agent
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
// ...
// Proxy utilities
export * from "./proxy.ts";
// Types
export * from "./types.ts";

pi/packages/ai/src/index.ts:L1-L2, L9-L10 — ai 包入口:type-only 再导出(编译后消失)

export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
// ...
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./api/anthropic-messages.ts";
export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";

对照本节讲的点:第一段是教科书式的 barrel —— packages/agent/src/index.ts 通篇 export * from "./子模块.ts",把 agent、loop、harness、types 等散在多个文件里的符号收拢到一个入口,并且每条路径都带 .ts 后缀。第二段展示 export type 与 export的对比:Static、TSchema 是纯类型(用 export type,编译后擦除),Type 是运行时真实存在的值(普通 export,保留)。同一个来源包 typebox 分两行导出,精确区分了「类型」和「值」两个世界 —— 这正是 pi 用 typebox 做运行时 schema、又从中派生静态类型的写法基础,后续interface 与 type会展开。

3.5 洞察

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。