第 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 | graph LR |
现状:TypeScript 在 Claude Code 里的真实面貌
先看看选了 TypeScript 之后,代码库实际上是什么样子。
1884 个文件,全是 TypeScript
src/ 目录下没有一个 .js 文件。这不是巧合——TypeScript 是硬性要求。编译器是守门人,类型系统是契约,没有通过类型检查的代码进不了代码库。
这意味着什么?意味着每个函数的参数和返回值都有类型标注,每个对象的形状都有定义,每个 import 都能被编译器验证。在一个近两千文件的项目里,这种保证不是奢侈品,是必需品。想象一下如果没有类型系统:你在某个文件的第 3000 行调用了一个函数,传了一个参数,你得靠搜索和记忆来确认这个函数期望什么类型。在 TypeScript 里,编译器替你做了这件事。
Zod:运行时的类型守卫
Claude Code 大量使用 Zod(zod/v4),这不是偶然。打开任何一个工具的定义——比如 BashTool、FileReadTool、WebSearchTool——你会看到:1
import { z } from 'zod/v4'
Zod 做了一件 TypeScript 自己做不了的事:运行时校验。TypeScript 的类型只在编译时存在,运行时全是 JavaScript。但 AI 返回的工具调用参数是从网络上收到的 JSON,没有任何保证它符合你的类型定义。Zod 在运行时替你守住了这道门。
在 Tool 类型里,你会看到工具的 call 方法大量使用 z.infer<Input>:1
2
3
4
5
6
7call(
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
7export interface Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P = unknown,
> {
// ...
}
Input 被 Zod schema 约束,Output 是工具返回的类型,P 是进度类型。每个工具有自己具体的 Input、Output 和 P,但所有工具共享同一个 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
5export 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,它在两个层面工作:
- 编译时:
z.infer<typeof schema>让 TypeScript 知道校验通过后数据的精确类型。 - 运行时:
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 缺失的方式是架构约定:
- 副作用集中在 Tool 层。AgentLoop 不直接做 I/O。代码审查时只需关注 Tool 实现。
async作为副作用的信号。虽然不完美,但async函数几乎一定有副作用(否则为什么异步?)readonly和Readonly<T>防止意外的状态修改- Zod Schema 做运行时屏障:Tool 的输入/输出经过 Zod 校验,防止被意外数据污染
如果要更进一步,TypeScript 生态中出现了受到 Haskell 启发的 Effect 库(effect-ts),它将副作用编码为泛型参数:1
2
3
4
5
6
7import { 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 的使用
搜索 : any 和 as 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 章:第一个真实贡献