Chapter 02
工作原理与设计取舍
第 1 章把八个零件背后的主线点了出来——类型只在编译期、运行时被擦除。这一章把这条主线本身讲透:类型为什么按「形状」而非「名字」匹配、擦除在编译流水线的哪一步发生、为什么这套类型系统故意不健全、strict 又替你打开了哪些检查。
本章你将建立的 schema
- 用「按形状、不按名字」解释两个无关类型为何可互换,以及多余属性检查这个补丁
- 能画出
tsc的「检查 → 擦除 → 生成」流水线,说清 tsx / Node 原生 /--noEmit各在哪一环 - 理解「绿色编译 ≠ 运行不崩」——
any/as是故意留的逃生舱,不能像信 javac 那样信 tsc - 会配
strict,知道它打开的每个子开关防住哪种错误
2.1结构化类型:按形状,不按名字
TS 判断「A 能不能赋给 B」只看 A 有没有 B 要求的全部成员(形状),不看它们叫什么、有没有继承关系。
这叫结构化类型(也叫鸭子类型)。第 1 章 §1.4 埋的伏笔在此收口:TS 的 interface 不需要 implements——任何对象只要形状对上,就自动算那个类型。兼容性由形状决定,与名字无关。
{ x; y } 形状、不同名字的两个类型。注意:TS 只看形状,二者随意互换;Java 看名字 + 继承,二者老死不相往来。这是 Java 工程师对 TS 最大的直觉重置。JS 代码满地都是匿名对象字面量、没有类名的东西({ x: 1, y: 2 } 随手就写)。名义类型(Java 那种「必须 implements 才算数」)根本没法描述这些既存的、无名的 JS 值,也没法支持「给一个旧 JS 项目逐步加类型」的渐进式迁移。结构化是 TS 能套在现成 JS 生态上的前提。
| 方案 | 兼容性怎么判定 | 代价 / 为什么 TS 没选它 |
|---|---|---|
| 名义类型(Java / C#) | 看声明的名字 + 继承链 | 描述不了无名的 JS 对象,迁移成本高 |
| 无类型(原始 JS) | 不判定,全靠运行时 | 没有任何编译期保护 |
| 结构化类型(TypeScript) | 看形状(有没有要求的成员) | 选中:能套现成 JS;代价见下文 |
两个本该区分的类型,只要形状一样就能互换——类型系统不替你拦。一个 { meters: number } 和 { seconds: number }……不,它们字段名不同所以不兼容;但 UserId 和 PostId 若都只是 number,就能随意混用,编译器不管。为了堵一类手滑,TS 加了个补丁:多余属性检查——把对象字面量直接赋值时,多出未声明的属性会报错。
const p: {x:number; y:number} = {x:1, y:2, z:3} 会报错吗?如果先 const tmp = {x:1,y:2,z:3} 再把 tmp 赋给 p 呢?
展开答案(先停 10 秒)
直接赋值:报错——多余属性检查发现 z 未在目标类型里声明。经过中间变量 tmp:不报错——此时走纯结构化规则,目标只要求「至少有 x、y」,tmp 满足,多出的 z 被忽略。
这两个看似矛盾的行为,根源是:多余属性检查是个只对「新鲜的对象字面量」生效的补丁,不是结构化规则本身。理解了这点,这个高频困惑就不再是玄学。
与下一节的关系:结构化解决了「编译期怎么判类型」。但这些类型到了运行时还在不在?这就是擦除。
2.2类型擦除与编译模型
tsc 先做类型检查,再把所有类型擦掉、生成纯 JS;这两步可以分开——很多工具只擦不查。
从你的 .ts 到能跑的代码,中间是一条流水线。关键是它有两个独立动作:类型检查、擦除生成。现代工具链把它们拆开了。
tsx 和 Node 原生为了快,只剥离类型、不做类型检查——代码照样跑,但错误不被拦。所以真实项目用它们跑、用 tsc --noEmit 在 CI 里单独把关。TS 的设计目标白纸黑字写着:「完全可擦除的类型系统」「零运行时开销」「不留运行时类型信息」。好处是编译产物就是干净标准的 JS,没有运行时负担,能跑在任何 JS 环境(浏览器、Node、边缘函数)。
| 工具 | 做什么 | 类型检查 | 典型用途 |
|---|---|---|---|
tsc | 官方编译器:检查 + 生成 .js | ✓ | 出包、CI 把关(--noEmit 只查不生成) |
tsx / esbuild | 极快地剥离类型直接跑 | ✗ | 本地开发、跑脚本 |
| Node 原生(≥22.18) | node file.ts 直接剥离类型 | ✗ | 无需额外依赖跑单文件 |
ts-node | 用真编译器跑(较慢) | 可选 | 旧项目,逐渐被 tsx 取代 |
运行时没有类型,是第 1 章一连串限制的同一个根因:不能反射类型(§1.1)、不能对 interface 用 instanceof(§1.8)、泛型拿不到 T(§1.7)。最现实的代价是:外部数据(API 响应、用户输入、读文件)不受类型系统保护。zod 这类库就是来补这个洞——它定义一个「运行时存在的 schema」,校验外部数据,并能反推出对应的静态类型。
// 从 API 拿数据,断言它是 User —— 危险
const user = (await res.json()) as User; // as:编译期骗过检查,运行时零验证
console.log(user.name.length); // 后端少给 name?运行时才 TypeError
// 正确做法:运行时校验(zod),schema 不会被擦除
import { z } from "zod";
const User = z.object({ name: z.string() });
const user2 = User.parse(await res.json()); // 形状不对当场抛错,且 user2 自动有类型
把 API 返回的 JSON 写成 as User 后,如果后端漏发了一个字段,TS 会在哪一步替你发现?
展开答案(先停 10 秒)
永远不会自动发现。as 是编译期断言,运行时被擦除,不产生任何检查代码。字段缺失要等到你访问 .missingField 拿到 undefined、或对它再取属性抛 TypeError 时才暴露——可能在离出错点很远的地方。这就是「外部数据要用 zod 校验、而不是 as」的全部理由。
与下一节的关系:既然 as 能骗过编译器、运行时又不检查,那 TS 的「编译通过」到底保证了什么?答案出人意料:它没保证运行不崩。
2.3「不健全」是故意的,以及 enum 这个例外
TS 明确把「健全 / 可证明正确」列为非目标:它知道有些会在运行时崩的程序,也照样放行。
「健全」(sound)指类型系统保证「编译通过 → 运行时类型一定不出错」。Java、C# 在很大程度上追求这个。TS 故意放弃了它,留了几个逃生舱:any(关掉检查)、as 断言(覆盖检查、不做运行时验证)、数组协变等。它们让你「自己说了算」,代价是编译器不再担保。
绿色编译 ≠ 运行不崩。Java 工程师习惯无条件信任 javac——编译过基本不会有类型错误。在 TS 里不能这样信任 tsc。尤其 as:它长得像 Java 强转,但 Java 强转运行时会检查(失败抛 ClassCastException),TS 的 as 运行时什么都不做。它只是命令编译器别检查、按你断言的类型算。
一个完全健全的类型系统,会拒绝大量「其实没问题」的 JS 惯用法,让给存量 JS 加类型变得几乎没法做到。TS 选了「实用 > 可证明正确」:用八成的检查覆盖九成九的错误,剩下的交给逃生舱、由你负责。这是个清醒的工程权衡,不是疏漏。
| 立场 | 编译通过意味着 | 取舍 |
|---|---|---|
| 健全(如 Elm / 多数函数式语言) | 运行时类型一定不出错 | 最安全,但会拒绝很多合法的动态写法 |
| 无类型(原始 JS) | 什么都不意味着 | 最灵活,零保护 |
| 不健全但实用(TypeScript) | 「基本没事」,但 any/as 处除外 | 选中:覆盖绝大多数错误 + 留逃生舱兼容 JS |
enum:擦除的那个例外
导览页的概念图埋了一句「enum 是少数会漏到运行时的类型语法」,这里收口。第 1 章说过类型几乎全被擦除,但 enum 不是——它会编译成一个真实存在的 JS 对象,还带正反双向映射:
enum 与字面量联合的运行时命运相反。注意:enum 是擦除规则的例外,留下真实对象(有体积、有反向映射带来的反直觉行为);字面量联合(§1.5)纯类型、零开销。这是社区多数场景偏向字面量联合的原因。const u = {} as User; console.log(u.name.length),编译过吗?运行时呢?
展开答案(先停 10 秒)
编译通过——as User 让编译器相信 {} 是个 User,跳过检查。运行时 u.name 是 undefined,undefined.length 抛 TypeError。这就是「不健全」最具体的样子:一行编译全绿的代码,运行直接崩。把 as 当成「我对编译器的承诺」,而不是「一次安全转换」。
与下一节的关系:既然编译器默认会放行不少危险写法,怎么把它调到「尽量严格」?这就是 strict。
2.4strict 配置:一个元开关
strict: true 不是一个检查,而是一次性打开约 8 个子检查的元开关。
在 tsconfig.json 里设 "strict": true,等于同时开启一组「更安全、但会拒绝部分旧代码」的检查。其中分量最重的是 strictNullChecks。TypeScript 6.0 起,strict 已是默认开启。
{
"compilerOptions": {
"strict": true, // 元开关:打开下面一整组检查
"target": "es2023", // 编译到哪个 JS 版本
"module": "nodenext", // 模块格式
"moduleResolution": "nodenext",
"esModuleInterop": true, // 兼容 CommonJS 默认导入
"skipLibCheck": true // 跳过第三方 .d.ts 检查,构建更快
}
}
| 子开关 | 关闭时的痛点 | 打开后 |
|---|---|---|
| strictNullChecks | null/undefined 可赋给任何类型,NPE 在运行时炸 | 空值必须显式处理,编译期拦下 |
| noImplicitAny | 推断不出类型时悄悄变 any,检查静默失效 | 逼你补注解或承认确实是 any |
| strictPropertyInitialization | 类字段忘了初始化,用时是 undefined | 字段必须初始化(类似 Java final 检查) |
| useUnknownInCatchVariables | catch (e) 里 e 是 any | e 是 unknown,用前必须收窄 |
strictNullChecks 针对的是 Tony Hoare 口中的「十亿美元错误」——null 引用。关闭时,TS 和早期 Java 一样:null 潜伏在每个类型里,运行时随时 NPE。打开后,string 就只是 string,想让它能为空必须写成 string | null 并显式处理。这是新项目第一件该确认开启的事(6.0 已默认开)。
关闭 strictNullChecks 时,function len(s: string) { return s.length } 传 null 进去,编译报错吗?
展开答案(先停 10 秒)
关闭时:不报错——null 被视为可赋给 string,运行时 null.length 抛错。打开 strictNullChecks 后:编译期就报错,因为 null 不再属于 string,要传得把参数类型写成 string | null 并在函数里先判空。把「空」从隐形变显形,正是这个开关的全部价值。
2.5跨概念综合:一次 API 取数据,用上几乎所有机制
把前面拆开的机制串成一个真实场景——从 API 取用户列表、渲染名字。读这段时,留意每一步踩在哪个机制上:
import { z } from "zod";
// 形状(§1.4 对象类型 + §2.1 结构化)
const User = z.object({ id: z.number(), name: z.string(), email: z.string().optional() });
type User = z.infer<typeof User>; // 从运行时 schema 反推静态类型
async function loadUsers(): Promise<User[]> {
const raw: unknown = await (await fetch("/api/users")).json(); // §1.2 unknown,不是 any
return z.array(User).parse(raw); // §2.2 擦除→必须运行时校验,而不是 as
}
function render(users: User[]): string[] {
return users.map(u =>
u.email ? `${u.name} <${u.email}>` : u.name // §1.8 收窄 + §2.4 strictNullChecks
);
}
梳理一遍因果链:fetch 回来的是 unknown(§1.2,因为运行时无类型、外部数据不可信,§2.2);所以不能 as,要用 zod 校验(§2.3 不健全 + §2.2 擦除);校验后得到的 User[] 靠结构匹配(§2.1);渲染时 email 是可选的,strictNullChecks(§2.4)逼你先收窄(§1.8)才能用。跳过 zod 这一步,前面所有类型保证都建在沙子上——因为类型在运行时不存在。
§本章 self-check
先合上教程作答,写完再展开对照。
- 结构化类型下,为什么
{x,y,z}字面量直接赋给{x,y}报错,但经过一个中间变量再赋值就不报错? tsx/ Node 原生跑 TS,和tsc有什么本质区别?为什么实践中还要单独跑一遍tsc --noEmit?- 用一个
as的例子说明「绿色编译 ≠ 运行不崩」。as和 Java 强转的关键差异在哪? - (跨机制)
strictNullChecks和「类型擦除」如何共同决定了「为什么 API 数据必须用 zod 校验,而不是as」?
答案(先做完再展开)
- 多余属性检查只对「新鲜的对象字面量直接赋值」生效,发现未声明的
z就报错;经过中间变量后走纯结构化规则,目标只要求「至少有 x、y」,多的忽略。后者才是结构化本身,前者是补丁。 tsx/Node 原生只剥离类型直接跑、不做类型检查;tsc会检查。所以光靠它们跑,类型错误不会被发现——需要在 CI 用tsc --noEmit单独把关。- 例:
const u = {} as User; u.name.length编译通过、运行抛TypeError。差异:Java 强转运行时会校验、失败抛ClassCastException;TS 的as运行时被擦除、零校验,只是骗过编译器。 - 擦除 → 运行时没有类型,所以外部数据进来时类型系统管不到;
strictNullChecks等只在编译期对「你声明的类型」生效,可一旦你用as谎报了形状,编译期检查是基于谎言做的,运行时又没有兜底。两者叠加 → 唯一可靠的办法是在运行时用 zod 真正验一遍。
在结构化系统里,造一个「名义类型」
§2.1 提到 UserId 和 PostId 若都是 number 就能互换,编译器不拦。设计一个类型技巧,让二者不能互相赋值——即在结构化系统里模拟出 Java 那样的名义区分。要求:运行时仍然就是普通 number,零开销。
提示(卡住再展开)
给类型「掺」一个现实中不存在、仅用于区分的标记字段:type UserId = number & { readonly __brand: "UserId" }。两个 brand 字符串不同 → 形状不同 → 不可互换。运行时那个 __brand 从不真正存在(被擦除),所以零开销。这招叫 branded types,是用「故意制造形状差异」去对抗结构化默认行为——理解了 §2.1,这个技巧就是自然推论。