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 有两种编译/运行路径,对本系列都重要:
tsc—— 官方编译器,把.ts检查并转译成.js(pi 的正式构建走这条,配合原生加速版tsgo)。tsx—— 一个「即时转译再执行」的运行器,让你不用先手动编译就能直接tsx foo.ts跑一个 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 洞察
- 类型是编译期的脚手架,不是运行时的对象。别指望在运行时
if (x is MyType)—— 类型这时已经不存在了;运行时判断要靠真实的 JS 值检查(typeof、字段探测),这正是窄化那一篇的主题。 - pi 里看不到
enum不是作者偏好,是erasableSyntaxOnly强制的;看到"a" | "b"这种字符串联合当"枚举"用时,就是这个原因。 strict: true是现代 TS 项目的事实标准。学习期就按严格模式练,免得养成会被编译器打回的习惯。
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 里:
- Node 版本:
engines.node声明最低 Node 版本。pi 要求>=22.19.0(Node 22 才把 ESM、原生.ts剥离等能力补齐,所以这是硬门槛)。 workspaces:一个 glob 数组,列出哪些目录是子包。"packages/*"表示packages/下每个目录都是一个 workspace。scripts:命名的命令快捷方式,用npm run <名字>触发。build(编译所有包)、check(lint + 类型检查)是最常用的两个。devDependencies:只在开发/构建时需要的工具,如tsx(直接跑.ts)、jiti(另一个.ts运行时加载器)、typescript、以及@biomejs/biome。
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 洞察
- 读 monorepo 从根
package.json的workspaces和scripts入手:前者告诉你有哪些包、依赖关系;后者告诉你作者是怎么 build / check / test 的。 - 想验证某个
.ts文件的真实行为,npx tsx 路径.ts最快 —— 不用管构建产物、不用配置,直接跑源码。 tsgo --noEmit/tsc --noEmit是「只检查类型、不产出 JS」的惯用法,常用于 CI 门禁;记住--noEmit这个组合。
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 洞察
- 读 pi 任何一个包,先打开它的
src/index.ts(barrel):那是这个包对外暴露的完整符号清单,等于一份目录。 export type { ... }/import type { ... }是「这行没有运行时效果」的显式声明;看到它就知道该符号纯属类型层,擦除后不留痕迹 —— 也不会造成循环依赖。- import 路径带
.ts是 pi 的特有配置(allowImportingTsExtensions),别在没开这个开关的项目里照抄,默认 ESM 里你得写.js或省略后缀。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。