第 41 章:为什么是 TypeScript

个人公众号

源码验证日期:2026-05-15,基于 commit 0d81bb6

你已经读完了三卷书。你追踪了消息的旅程,拆开了引擎室的每台机器,还在造物主的工坊里亲手搭过东西。你知道 Claude Code 怎么工作,知道它的代码长什么样,甚至能自己改几行了。

但有一件事我们从来没有问过:为什么是 TypeScript?

这个问题看起来简单——翻开 src/ 看到的全是 .ts.tsx 文件,1884 个,一个例外都没有。但如果在 2024 年做一个 AI 编程助手的 CLI 工具,摆在桌面上的选项远不止 TypeScript 一个。Python 是 AI 领域的霸主,Rust 有性能和安全,Go 有简洁和速度,甚至连纯 JavaScript 都能说一句”我更简单”。

选了 TypeScript,不是因为它”最好”,而是因为在那个时间点、那些约束条件下,它是权衡之后最合理的选择。

这一章我们讨论这个权衡。


本章路线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graph LR
CH41["第41章<br/>为什么是TypeScript"] --> CH42["第42章<br/>为什么是React/Ink"]
CH42["第42章<br/>为什么是React/Ink"] --> CH43["第43章<br/>为什么用Zod"]
CH43["第43章<br/>为什么用Zod"] --> CH44["第44章<br/>工具系统的演进"]
CH44["第44章<br/>工具系统的演进"] --> CH45["第45章<br/>安全与便利"]
CH45["第45章<br/>安全与便利"] --> CH46["第46章<br/>有限窗口"]
CH46["第46章<br/>有限窗口"] --> CH47["第47章<br/>大AsyncGenerator"]
CH47["第47章<br/>大AsyncGenerator"] --> CH48["第48章<br/>Agent架构"]
CH48["第48章<br/>Agent架构"] --> CH49["第49章<br/>开放协议"]
CH49["第49章<br/>开放协议"] --> CH50["第50章<br/>性能的故事"]
CH50["第50章<br/>性能的故事"] --> CH51["第51章<br/>纵深防御"]
CH51["第51章<br/>纵深防御"] --> CH52["第52章<br/>稳定、历史与未来"]

style CH41 fill:#4a90d9,stroke:#2c5f8a,color:#fff,stroke-width:3px
style CH42 fill:#e0e0e0,stroke:#999
style CH43 fill:#e0e0e0,stroke:#999
style CH44 fill:#e0e0e0,stroke:#999
style CH45 fill:#e0e0e0,stroke:#999
style CH46 fill:#e0e0e0,stroke:#999
style CH47 fill:#e0e0e0,stroke:#999
style CH48 fill:#e0e0e0,stroke:#999
style CH49 fill:#e0e0e0,stroke:#999
style CH50 fill:#e0e0e0,stroke:#999
style CH51 fill:#e0e0e0,stroke:#999
style CH52 fill:#e0e0e0,stroke:#999

现状:TypeScript 在 Claude Code 里的真实面貌

先看看选了 TypeScript 之后,代码库实际上是什么样子。

1884 个文件,全是 TypeScript

src/ 目录下没有一个 .js 文件。这不是巧合——TypeScript 是硬性要求。编译器是守门人,类型系统是契约,没有通过类型检查的代码进不了代码库。

这意味着什么?意味着每个函数的参数和返回值都有类型标注,每个对象的形状都有定义,每个 import 都能被编译器验证。在一个近两千文件的项目里,这种保证不是奢侈品,是必需品。想象一下如果没有类型系统:你在某个文件的第 3000 行调用了一个函数,传了一个参数,你得靠搜索和记忆来确认这个函数期望什么类型。在 TypeScript 里,编译器替你做了这件事。

Zod:运行时的类型守卫

Claude Code 大量使用 Zod(zod/v4),这不是偶然。打开任何一个工具的定义——比如 BashToolFileReadToolWebSearchTool——你会看到:

1
import { z } from 'zod/v4'

Zod 做了一件 TypeScript 自己做不了的事:运行时校验。TypeScript 的类型只在编译时存在,运行时全是 JavaScript。但 AI 返回的工具调用参数是从网络上收到的 JSON,没有任何保证它符合你的类型定义。Zod 在运行时替你守住了这道门。

Tool 类型里,你会看到工具的 call 方法大量使用 z.infer<Input>

1
2
3
4
5
6
7
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>

z.infer 是 TypeScript 和 Zod 之间的桥梁——你定义一份 Zod schema,它同时给你编译时的类型推断和运行时的校验能力。一份定义,两种用途。这种模式在 Claude Code 里反复出现,几乎成了项目的惯例(第 43 章会深入讨论这个选择)。

泛型和联合类型:复杂性的容器

Claude Code 的类型系统不是简单几行 interface 就能概括的。Tool 类型本身就是一个复杂的泛型结构:

1
2
3
4
5
6
7
export interface Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P = unknown,
> {
// ...
}

Input 被 Zod schema 约束,Output 是工具返回的类型,P 是进度类型。每个工具有自己具体的 InputOutputP,但所有工具共享同一个 Tool 接口。这就是泛型的价值:一套接口,无限种具体形态(第 44 章会展开这个设计)。

联合类型也无处不在。权限系统里:

1
export type PermissionBehavior = 'allow' | 'deny' | 'ask'

这些不是 enum,是字面量联合类型。TypeScript 的联合类型比 enum 更轻量,也更灵活。配合 exhaustive check(穷举检查),你能在编译时保证每种情况都被处理了。

async generator:流式处理的自然表达

Claude Code 最核心的模式之一是流式处理。AI 的回答一个字一个字地回来,工具的执行结果逐步产生,对话的状态不断变化。在 TypeScript 里,这些场景用 async function*(异步生成器)表达得非常自然:

1
2
3
4
5
export async function* runToolUse(
// ...
): AsyncGenerator<ToolUseResult> {
// yield 每一步的结果
}

async function* + yield + for await...of 三件套,让”逐步产生结果、逐步消费结果”的代码写起来像同步代码一样直观。TypeScript 的类型系统让异步生成器的输入输出都有类型保障,这在复杂的数据流中特别有价值。

在整个流式 API 调用链条里,queryModel 是一个异步生成器,queryModelWithStreaming 包装它,层层嵌套的 yield* 委托,类型系统全程跟踪每个 yield 出来的数据类型(第 47 章会专门讨论核心循环的 AsyncGenerator 设计)。

React + Ink:终端 UI 的捷径

552 个 .tsx 文件。这意味着整个终端界面是用 React 写的,通过 Ink 框架渲染到终端。这本身就是一个语言选择的直接后果——React 生态在 TypeScript 里,Ink 是 React 的终端适配器,选了 TypeScript 就直接获得了这条通路。

如果你用 Python 或 Rust,终端 UI 就得从头写,或者用那些远没有 React 生态成熟的框架(第 42 章会详细展开这个选择)。


当时还有什么选择

2024 年的 Anthropic 团队站在白板前时,手里有哪些牌?

Python:AI 世界的母语

Python 是 AI/ML 领域的默认选项。Pydantic 做运行时校验(类似 Zod),类型标注(Type Hints)有了但还远不如 TypeScript 成熟,async/await 支持也有了但性能和开发体验差距明显。

优势很明显:团队里做 AI 的人都会 Python,跟 Anthropic 的 Python SDK 无缝对接,Jupyter 生态随手可用。

但 Claude Code 不是一个 ML 训练管线。它是一个终端应用——需要精细的终端渲染、实时的键盘事件处理、复杂的异步数据流。Python 在这些领域的生态远不如 JavaScript。没有 Ink,没有 React-for-terminal,你要么用 Textual(Python 的终端 UI 框架,当时还很年轻),要么自己造轮子。

还有性能。Python 的 GIL 在并发场景下是硬伤。Claude Code 同时要处理用户输入、API 流式响应、工具执行、UI 渲染——这些并发需求在 Node.js 的事件循环模型里处理起来更自然。

Rust:性能和安全

Rust 在 CLI 工具领域有越来越强的存在感。ripgrep、exa、bat、fd——这些现代命令行工具都是 Rust 写的。性能卓越,内存安全,编译时检查严格。

但 Rust 的代价太大了。开发速度慢——同样的功能,Rust 的代码量通常是 TypeScript 的两到三倍,因为你要处理所有权、生命周期、错误传播。在一个快速迭代的产品里,”这周改了需求,下周要上线”的节奏下,Rust 的编译时间和心智负担会成为瓶颈。

对于一个 AI 编程助手的 CLI 来说,瓶颈不在本地计算性能——瓶颈在网络 I/O(等 AI 的回复)和人机交互。Rust 提供的性能优势在这个场景下根本用不上。

Go:简洁和速度

Go 在 CLI 工具领域有传统:Docker、kubectl、Terraform、hugo。编译快,部署简单(单一二进制文件),goroutine 让并发编程变得轻量。

Go 的问题在于类型系统不够强。没有泛型直到 1.18 才加入,而且即使有了泛型,Go 的类型表达力也远不如 TypeScript。Claude Code 那种 Tool<Input, Output, P> 的泛型架构,在 Go 里要么写不出来,要么要写大量的 interface{} 和类型断言。

Go 也没有 React 级别的 UI 框架。终端 UI 的选择有限——Bubble Tea 是最好的选择,但它远没有 React 的组件化那么成熟和灵活。

纯 JavaScript:去掉类型层

“既然 JavaScript 就够了,为什么要加 TypeScript?”

2020 年这个争论还有意义。2024 年,在近两千文件的项目里用纯 JavaScript 已经不是一个严肃的选项了。没有类型标注,你无法安全地重构;没有编译器检查,你无法在 CI 里捕获类型错误;没有 IDE 的类型感知,你无法在大规模代码库里高效导航。

纯 JavaScript 只在一个场景下有优势:原型阶段,代码量小,一个人开发。Claude Code 早已过了那个阶段。


为什么选了 TypeScript

每个选择都是一组理由的叠加。不是单一理由决定的。

理由一:React + Ink 这条路直接通了

这是最硬的技术理由。Claude Code 的终端界面是一个复杂的、实时的、交互丰富的 UI——工具执行状态、流式文字输出、权限对话框、多面板布局、Vim 模式、键盘快捷键。用 React + Ink,这些需求有了现成的解法:组件化、状态管理、声明式渲染。

552 个 .tsx 文件的存在证明了这一点。如果用 Python,这些文件要么不存在(功能受限),要么每个文件都要花更多时间从零构建。React + Ink 不是完美的,但它是一条已经修好的高速公路。

理由二:Zod + TypeScript 的双重保障

AI 编程助手有一个独特的挑战:它的很多输入不是来自人类程序员,而是来自 AI 模型。AI 返回的 JSON 可能不符合你的期望——字段名错了、类型错了、结构嵌套错了。你需要一个强壮的校验层。

Zod + TypeScript 的组合在这个场景下几乎是完美的。你定义一个 Zod schema,它在两个层面工作:

  1. 编译时z.infer<typeof schema> 让 TypeScript 知道校验通过后数据的精确类型。
  2. 运行时schema.parse(data) 在收到数据时实际校验,不符合就抛错。

这在 Claude Code 里不只是锦上添花。每一个工具的输入参数、每一个 API 响应、每一个配置文件——全都经过这种双重保障。没有这层保障,调试 AI 返回的错误数据会成为日常噩梦。

理由三:异步是 DNA

JavaScript 的异步模型——事件循环、Promise、async/await、async generator——是这个语言的 DNA。Claude Code 的核心就是异步的:等用户输入、等 API 响应、等工具执行、等流式数据。

在 TypeScript 里,这些操作写起来是自然的。async function* 处理流式数据,Promise.all 处理并发,AbortController 处理取消。语法层面几乎没有摩擦。

对比 Python 的 asyncio:虽然也能做到,但生态和语法体验差距明显。对比 Rust 的 async:功能更强大但心智负担重得多。对比 Go 的 goroutine:并发模型不同,goroutine 更适合 CPU 并行,而 JavaScript 的事件循环更契合 I/O 密集型的网络应用。

理由四:团队和生态

这可能是最实际的理由。Anthropic 的工程团队里,TypeScript/JavaScript 开发者的供应量远大于 Rust 或 Go 开发者。npm 生态里什么都有——命令行解析、终端颜色、Schema 校验、测试框架——不需要造轮子。

Node.js/Bun 的运行时也在快速进步。Bun 的启动速度比 Node.js 快得多,对于一个每次命令行调用都要启动的 CLI 工具来说很重要。选择 TypeScript 不是选择了某个固定版本的语言,而是选择了一条在持续改进的路径。


如果重新设计

假设今天,2026 年,从零开始重写 Claude Code。同样的需求、同样的规模、同样的团队约束。会做出同样的选择吗?

大概率会。但有些变化值得注意。

Bun 的成熟改变了部署体验。 2024 年选 TypeScript 时,Bun 还在快速迭代中,稳定性有疑虑。2026 年的 Bun 已经足够成熟,单文件打包(bun build --compile)让 TypeScript CLI 工具的部署体验接近 Go 的单一二进制文件。这消除了 TypeScript 的一个传统劣势:部署时需要运行时。

TypeScript 本身也在进步。 5.x 版本的类型系统越来越强,推断越来越好,装饰器稳定了,satisfies 操作符让类型约束更灵活。

但 Python 也在追。 Python 的类型标注越来越成熟,Pydantic V2 的性能大幅提升,UV 包管理器解决了长期以来的依赖管理痛点。如果 Anthropic 的团队主要是 Python 文化,2026 年用 Python 重写也不是一个离谱的选择。只是终端 UI 的生态差距仍然存在。

Rust 的可能性更有意思了。 如果 Claude Code 的性能瓶颈从网络 I/O 转移到本地计算(比如本地模型推理、大规模文件分析),Rust 的性能优势会变得有意义。但只要核心瓶颈还是”等 AI 回复”,Rust 的优势就发挥不出来。


TypeScript 的盲区:副作用不可见

选了 TypeScript,就不得不接受它的一个根本性限制:类型系统不追踪副作用(Effect)

什么是 Effect

一个函数如果除了返回值还改变了外部世界——写文件、发网络请求、修改全局状态——它就产生了副作用。在 Agent 框架中,几乎所有 Tool 都有副作用:

1
2
3
4
5
6
7
// 这些函数的签名看不出副作用
Tool<Input, Output>.call(input: Input): Promise<Output>
// 它可能:读文件(副作用小)、删除文件(副作用大)、发网络请求(有副作用)

// 相比之下,纯函数的签名诚实得多
function add(a: number, b: number): number
// 你 100% 确定它只做加法,不删文件

某些语言(Haskell 的 IO Monad、Rust 的 ownership)在类型系统中编码副作用。在 TypeScript 中,副作用是隐式的。

Agent 框架中的影响

隐式副作用的代价在 Agent 框架中特别明显:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这段代码看起来安全
class AgentLoop {
async *run(input: string): AsyncGenerator<AgentEvent> {
const response = await this.client.createMessage({ /*...*/ })
// ↑ 副作用:网络请求,可能花费 $$

for (const block of response.content) {
if (block.type === "tool_use") {
const result = await this.executeTool(block)
// ↑ 副作用:可能修改文件、运行命令
yield { type: "tool_result", data: result }
}
}
}
}

你无法从类型签名知道 executeTool 会不会删文件。你只能信任 Tool 的 isReadOnly 属性——这是一个运行时约定,不是编译时保证。

弥补策略

TypeScript 社区弥补 Effect 缺失的方式是架构约定

  1. 副作用集中在 Tool 层。AgentLoop 不直接做 I/O。代码审查时只需关注 Tool 实现。
  2. async 作为副作用的信号。虽然不完美,但 async 函数几乎一定有副作用(否则为什么异步?)
  3. readonlyReadonly<T> 防止意外的状态修改
  4. Zod Schema 做运行时屏障:Tool 的输入/输出经过 Zod 校验,防止被意外数据污染

如果要更进一步,TypeScript 生态中出现了受到 Haskell 启发的 Effect 库(effect-ts),它将副作用编码为泛型参数:

1
2
3
4
5
6
7
import { Effect } from "effect"

// 用 Effect 类型标注副作用:需要文件系统和网络
declare function readConfig(path: string): Effect.Effect<Config, Error, FileSystem | Network>

// 纯函数——无副作用
declare function parseConfig(raw: string): Config

但这种模式在 TypeScript 生态中远非主流。Claude Code 没有用它,你的 Agent 框架大概率也不会用它。重要的是意识到这个盲区的存在,并通过架构约定来管理它,而不是假装副作用不存在。


试试看

练习一:搜索类型断言的使用

在 Claude Code 源码中搜索 as 类型断言的使用频率。哪些地方确实需要类型断言,哪些地方可以用更好的类型定义替代?

练习二:检查 any 的使用

搜索 : anyas any 的出现位置。在一个追求类型安全的项目里,any 通常意味着”这里类型系统帮不了我们”。看看这些地方为什么不能用更精确的类型。

练习三:对比 Python 实现

找一个 Claude Code 的核心模块(比如 Tool 类型或 permissions.ts),试着用 Python + Pydantic 写一个等价的类型定义。感受两种语言在类型表达力上的差异。


检查点

  • TypeScript 是 Claude Code 的唯一语言:1884 个 .ts/.tsx 文件,零 .js 文件
  • Zod 提供运行时校验:补充 TypeScript 编译时检查无法覆盖的网络数据场景
  • 泛型架构支撑工具系统Tool<Input, Output, P> 一套接口,三十多种具体形态
  • async generator 天然契合流式处理yield + for await...of 处理 AI 的流式响应
  • React + Ink 是 TypeScript 选择的直接收益:552 个 .tsx 文件的终端 UI
  • 替代方案各有硬伤:Python 缺终端 UI 生态,Rust 开发慢,Go 类型弱,纯 JS 无保障

导航

上一章:第 40 章:第一个真实贡献

下一章:第 42 章:为什么是 React/Ink