CHAPTER 04 · 语言
TypeScript 类型系统
如果你来自 Java 或 C#,TS 的类型看起来很眼熟,但底层哲学不同:它描述的是数据的形状,而不是类的血统;它只在编写时存在,运行时一点痕迹都没有。理解这两点,就知道类型能保护你什么、不能保护你什么。
- 解释结构化类型(structural typing)与 Java 名义类型的区别,并预测赋值是否合法。
- 用判别联合(discriminated union)为有多种状态的数据建模,并写出穷尽检查。
- 说明为什么 API 响应、表单、LLM 输出必须在运行时校验,并用 Zod 做到“一次定义,类型与校验同源”。
- 在代码审查中认出危险的“逃生舱”:
any、as、!、@ts-ignore。
开场:类型全绿,线上还是崩了
LinkNote 的代码 tsc 零错误。某天线上报错:TypeError: Cannot read properties of undefined (reading 'length'),位置在 link.tags.length。Link 类型里明明写着 tags: string[],不可能是 undefined。类型系统为什么没拦住?
答案方向:那条数据来自一个外部接口,代码里写的是 const link = await res.json() as Link。as 的意思是“编译器,相信我”——编译器相信了,但运行时的数据并没有 tags 字段。TS 的类型是你对数据形状的“声明”,编译器只检查你的代码与声明是否一致,从不检查真实数据是否符合声明。所以,凡是从程序外部进来的数据,都必须在运行时校验一次。
结构化类型:看形状,不看出身
在 Java 里,类型兼容取决于声明关系:class Dog implements Animal,Dog 才能当 Animal 用。这叫名义类型(nominal typing)。TypeScript 用的是结构化类型(structural typing):只要形状对得上,就兼容,不需要任何声明。
这很像 Go 的接口(隐式实现),只是范围扩大到了所有类型,包括普通对象。
两个形状相同的类型可以互相赋值,哪怕语义完全不同:type UserId = string 和 type LinkId = string 对编译器来说是同一个东西,把用户 ID 传给需要链接 ID 的函数不会报错。需要区分时,可以用“品牌类型(branded type)”技巧:type LinkId = string & { __brand: "LinkId" }。知道
基础积木
| 写法 | 含义 |
|---|---|
string、number、boolean | 原始类型(JS 只有一种数字类型:64 位浮点) |
string[] 或 Array<string> | 数组 |
[string, number] | 元组:固定长度、每位类型固定 |
{ url: string; title?: string } | 对象类型;? 表示可选(值可能是 undefined) |
readonly string[] | 只读(仅编译期约束) |
"pending" | "done" | 字面量类型的联合:只能是这两个字符串之一 |
string | null | 联合类型:可能是 string,也可能是 null |
A & B | 交叉类型:同时具有 A 和 B 的所有字段 |
unknown | “不知道是什么”,使用前必须先收窄 —— 安全 |
any | “关闭类型检查” —— 危险,会像病毒一样传播 |
never | 不可能存在的值(用于穷尽检查) |
type 还是 interface?两者在描述对象形状时几乎可以互换。interface 可以被重复声明并自动合并、报错信息有时更友好;type 能表达联合、元组等任意类型。实践中:团队统一一种即可,很多项目默认用 type,需要被扩展的公共对象形状用 interface。这是风格问题,不是正确性问题。
联合类型与收窄 原理
联合类型是 TS 最有表达力的特性,也是它与 Java 类型系统差异最大的地方。一个值的类型是 string | null 时,你不能直接调用 .length——必须先证明它不是 null。证明的过程叫收窄(narrowing):编译器会跟踪你的 if、return、typeof、in 等判断,在不同代码位置赋予同一个变量不同的类型。
判别联合:为“多状态数据”建模
LinkNote 的每个收藏都有状态:处理中、已完成、失败。不同状态下拥有的字段不同:完成才有摘要,失败才有错误信息。Java 程序员的第一直觉通常是写一个大类,把所有字段都设为可空:
判别联合的做法是:每种状态一个形状,用一个共同的字面量字段(判别字段)区分。这样“非法状态”根本无法被表示出来:
这种写法在 TS 代码中无处不在:请求状态(loading / success / error)、LLM 流式消息的片段类型(text / tool-call / tool-result)、Redux 的 action、事件类型……看到 type: "..." 或 kind: "..." 字段,就应该想到它。
穷尽检查:让编译器提醒你漏了哪个分支
以后有人给 Bookmark 加了第四种状态 archived,所有漏掉它的 switch 都会在编译期报错。这就是类型系统最有价值的地方:把“改了一处、忘了另一处”的 bug 变成编译错误。在 Agent 大规模修改代码的时代,这种保护尤其重要。
类型推断:少写类型,写在关键处
TS 的类型推断很强。大部分局部变量不需要标注类型,编译器能推出来。一个实用的原则:
- 必须标注:函数参数;模块导出的公共函数的返回类型(作为契约);外部数据的入口。
- 让它推断:局部变量、回调参数、简单函数的返回值。
两个常用的“辅助推断”关键字:
typeof STATUSES 这一行展示了一个重要习惯:从一个“单一事实来源”推导出类型,而不是在两处分别手写,避免它们不同步。
泛型
泛型(generics)让函数或类型对“某个待定的类型”保持通用,同时保留类型信息。和 Java 泛型概念一致,但 TS 的泛型更常用于推导:
这和开场问题中的 as Link 本质相同:类型参数只是你的“声明”。Agent 非常喜欢写这种函数。正确的做法是把一个校验器传进去(见下一节)。
工具类型:从已有类型推导新类型 会用
| 工具类型 | 作用 | LinkNote 例子 |
|---|---|---|
Partial<T> | 所有字段变可选 | 更新接口的请求体 |
Pick<T, K> / Omit<T, K> | 挑选 / 去掉某些字段 | Omit<Link, "id" | "createdAt"> 作为创建参数 |
Record<K, V> | 键为 K、值为 V 的对象 | Record<Status, string> 状态到文案的映射 |
ReturnType<F> / Awaited<P> | 取函数返回类型 / 解开 Promise | 从查询函数推出数据类型 |
keyof T / T[K] | 取键的联合 / 取某字段的类型 | Link["tags"] |
你会在库的类型声明里看到更复杂的“类型体操”(条件类型、映射类型、模板字面量类型)。知道 它们存在即可:读懂报错比会写更重要,写复杂类型的事可以交给 Agent,但要求它附上类型测试或使用示例。
类型擦除与边界校验 原理
回到开场的问题。因为类型在运行时不存在,程序里的数据可以分成两类:
Zod:一次定义,同时得到校验器和类型
Zod 是 TS 生态中运行时校验的事实标准(类似库还有 Valibot、ArkType,它们大多实现了统一的 Standard Schema 接口,框架可以通用地接受)。它的核心价值:你只写一份 schema,TypeScript 类型从 schema 推导出来,两者永远不会不一致。
之后你会在很多地方看到同一个 Zod schema 被重复利用:表单校验、API 入参校验、环境变量校验、数据库查询结果校验,以及——非常重要的——约束 LLM 的结构化输出(第 11 章)。
逃生舱:什么时候可以用,什么时候是危险信号
| 写法 | 含义 | 审查建议 |
|---|---|---|
any | 关闭该值的全部检查,并传染给所有用到它的地方 | 🚩 几乎总是应该改成 unknown + 收窄或校验 |
x as T | 类型断言:“相信我,它是 T” | 🚩 对外部数据使用时尤其危险;对 DOM 元素等少数场景可接受 |
x! | 非空断言:“相信我,它不是 null” | ⚠️ 要求说明为什么一定不为空;通常可以改成显式判断 |
// @ts-ignore | 忽略下一行的任何错误 | 🚩 不允许;确有必要时用 @ts-expect-error 加注释说明原因 |
as unknown as T | 双重断言,强行转换任意类型 | 🚩 基本等于承认建模有问题 |
这些规则可以用 Lint 自动执行(如 typescript-eslint 的 no-explicit-any、no-non-null-assertion,Biome 有对应规则),再写进 AGENTS.md。人和 Agent 都会“为了让报错消失”而走捷径,机器检查不会心软。
关于 enum
从 Java/C# 来的人会自然地想用 enum。在现代 TS 里,更推荐字符串字面量联合或 as const 对象:它们是纯类型,能被直接擦除(Node 的类型剥离不支持 enum,因为 enum 会生成运行时代码),而且和 JSON、数据库里的字符串值天然对应。
读懂类型报错
TS 的报错有时长达几十行。三个技巧:
- 从最后一行往上读。最底部通常是最具体的不匹配原因(“属性 X 在类型 Y 中缺失”)。
- 悬停看两边的类型。分别看“期望的类型”和“实际的类型”,差异就是答案。
- 让 Agent 解释,而不是让 Agent 修。先问“这个报错说明我的数据模型哪里不一致?”,理解后再决定改类型还是改代码。直接说“修掉这个报错”,常常会得到一个
as。
status 为判别字段的判别联合:pending(url)、done(url、summary、tags)、failed(url、error)。用 Zod 定义 schema 并用 z.infer 导出类型。所有 switch 加穷尽检查。不要用可选字段表示状态相关的数据。”- any 和 unknown 的区别?都能接收任意值。any 关闭检查、可以随意使用;unknown 必须先收窄才能使用。外部数据应该是 unknown。
- 什么是结构化类型?与 Java 有何不同?兼容性由形状决定而不是声明关系;举一个类未声明实现接口但可以赋值的例子。
- 为什么 TS 项目还需要 Zod?类型擦除:运行时没有类型信息,外部数据必须在边界校验。Zod 让 schema 成为唯一事实来源,类型由它推导。
- 什么是判别联合?解决什么问题?用共同的字面量字段区分多种形状,让非法状态不可表示,并借助控制流分析自动收窄。
自检
1. type A = { id: string },const x = { id: "1", extra: true }。const a: A = x 合法吗?const b: A = { id: "1", extra: true } 呢?
第一个合法:结构化类型只要求“至少有” id。第二个会报错:直接把对象字面量赋给有类型的变量时,TS 会额外做“多余属性检查(excess property check)”,因为这通常意味着拼写错误。这个特殊检查只对“新鲜的”对象字面量生效。
2. LLM 返回了一段 JSON,你用 JSON.parse(text) as Summary 得到数据。列出至少两种可能在运行时出现、但类型系统无法发现的问题。
字段缺失(模型漏写了 tags);字段类型不对(tags 是一个逗号分隔的字符串而不是数组);多出不认识的字段;枚举值超出范围(status 写成了 "completed")。应使用 SummarySchema.safeParse,失败时重试或降级处理。
3. 给 Bookmark 新增 { status: "archived"; url: string }。在有穷尽检查和没有穷尽检查的两个代码库里,分别会发生什么?
有穷尽检查:所有漏处理 archived 的 switch 在编译期报错,列出需要修改的位置。没有:代码照常编译,archived 的收藏会落入 default 分支或者被当成某种错误状态处理,bug 直到用户遇到时才暴露。