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——任何对象只要形状对上,就自动算那个类型。兼容性由形状决定,与名字无关。

TypeScript · 按结构 Point2D { x; y } Vec2 { x; y } ✓ 可互相赋值 形状相同即兼容 Java · 按名字 Point2D { x; y } Vec2 { x; y } ✗ ✗ 不兼容 无继承关系即不兼容
图 2.1同样是 { x; y } 形状、不同名字的两个类型。注意:TS 只看形状,二者随意互换;Java 看名字 + 继承,二者老死不相往来。这是 Java 工程师对 TS 最大的直觉重置。
为什么这么设计

JS 代码满地都是匿名对象字面量、没有类名的东西({ x: 1, y: 2 } 随手就写)。名义类型(Java 那种「必须 implements 才算数」)根本没法描述这些既存的、无名的 JS 值,也没法支持「给一个旧 JS 项目逐步加类型」的渐进式迁移。结构化是 TS 能套在现成 JS 生态上的前提。

表 2.1 · 三种类型立场
方案兼容性怎么判定代价 / 为什么 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 到能跑的代码,中间是一条流水线。关键是它有两个独立动作:类型检查、擦除生成。现代工具链把它们拆开了。

.ts 源码 值 + 类型 tsc 类型检查 报错就停 擦除 → 生成 类型在此丢弃 运行时 · 纯 JS 无任何类型 tsx / Node 原生:直接剥离类型,跳过检查 所以类型把关要靠 CI 里单独跑 tsc --noEmit
图 2.2编译流水线,类型在「擦除 → 生成」这一步丢弃。注意那条虚线绕行:tsx 和 Node 原生为了快,只剥离类型、不做类型检查——代码照样跑,但错误不被拦。所以真实项目用它们跑、用 tsc --noEmit 在 CI 里单独把关。
为什么这么设计

TS 的设计目标白纸黑字写着:「完全可擦除的类型系统」「零运行时开销」「不留运行时类型信息」。好处是编译产物就是干净标准的 JS,没有运行时负担,能跑在任何 JS 环境(浏览器、Node、边缘函数)。

表 2.2 · 2026 年运行 TS 代码的几种方式
工具做什么类型检查典型用途
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」,校验外部数据,并能反推出对应的静态类型。

擦除的代价:as 不是运行时检查TypeScript
// 从 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 选了「实用 > 可证明正确」:用八成的检查覆盖九成九的错误,剩下的交给逃生舱、由你负责。这是个清醒的工程权衡,不是疏漏。

表 2.3 · 健全性的三种立场
立场编译通过意味着取舍
健全(如 Elm / 多数函数式语言)运行时类型一定不出错最安全,但会拒绝很多合法的动态写法
无类型(原始 JS)什么都不意味着最灵活,零保护
不健全但实用(TypeScript)「基本没事」,但 any/as 处除外选中:覆盖绝大多数错误 + 留逃生舱兼容 JS

enum:擦除的那个例外

导览页的概念图埋了一句「enum 是少数会漏到运行时的类型语法」,这里收口。第 1 章说过类型几乎全被擦除,但 enum 不是——它会编译成一个真实存在的 JS 对象,还带正反双向映射:

enum Color { Red, Green } tsc 运行时留下真实 JS 对象 { Red: 0, Green: 1, 0: "Red", 1: "Green" } 正向 + 反向双映射 type Color = "Red" | "Green" tsc 运行时:什么都不剩 零开销(被擦除)
图 2.3同样表达「颜色只能是 Red 或 Green」,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 已是默认开启。

tsconfig.json(最小够用)JSON
{
  "compilerOptions": {
    "strict": true,            // 元开关:打开下面一整组检查
    "target": "es2023",        // 编译到哪个 JS 版本
    "module": "nodenext",      // 模块格式
    "moduleResolution": "nodenext",
    "esModuleInterop": true,   // 兼容 CommonJS 默认导入
    "skipLibCheck": true       // 跳过第三方 .d.ts 检查,构建更快
  }
}
表 2.4 · strict 打开的关键子检查 → 各自防住的痛点
子开关关闭时的痛点打开后
strictNullChecksnull/undefined 可赋给任何类型,NPE 在运行时炸空值必须显式处理,编译期拦下
noImplicitAny推断不出类型时悄悄变 any,检查静默失效逼你补注解或承认确实是 any
strictPropertyInitialization类字段忘了初始化,用时是 undefined字段必须初始化(类似 Java final 检查)
useUnknownInCatchVariablescatch (e) 里 e 是 anye 是 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 取用户列表、渲染名字。读这段时,留意每一步踩在哪个机制上:

一个场景串起全章TypeScript
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

先合上教程作答,写完再展开对照。

  1. 结构化类型下,为什么 {x,y,z} 字面量直接赋给 {x,y} 报错,但经过一个中间变量再赋值就不报错?
  2. tsx / Node 原生跑 TS,和 tsc 有什么本质区别?为什么实践中还要单独跑一遍 tsc --noEmit?
  3. 用一个 as 的例子说明「绿色编译 ≠ 运行不崩」。as 和 Java 强转的关键差异在哪?
  4. (跨机制)strictNullChecks 和「类型擦除」如何共同决定了「为什么 API 数据必须用 zod 校验,而不是 as」?
答案(先做完再展开)
  1. 多余属性检查只对「新鲜的对象字面量直接赋值」生效,发现未声明的 z 就报错;经过中间变量后走纯结构化规则,目标只要求「至少有 x、y」,多的忽略。后者才是结构化本身,前者是补丁。
  2. tsx/Node 原生只剥离类型直接跑、不做类型检查;tsc 会检查。所以光靠它们跑,类型错误不会被发现——需要在 CI 用 tsc --noEmit 单独把关。
  3. 例:const u = {} as User; u.name.length 编译通过、运行抛 TypeError。差异:Java 强转运行时会校验、失败抛 ClassCastException;TS 的 as 运行时被擦除、零校验,只是骗过编译器。
  4. 擦除 → 运行时没有类型,所以外部数据进来时类型系统管不到;strictNullChecks 等只在编译期对「你声明的类型」生效,可一旦你用 as 谎报了形状,编译期检查是基于谎言做的,运行时又没有兜底。两者叠加 → 唯一可靠的办法是在运行时用 zod 真正验一遍。
进阶挑战 · 刚好够不着

在结构化系统里,造一个「名义类型」

§2.1 提到 UserId 和 PostId 若都是 number 就能互换,编译器不拦。设计一个类型技巧,让二者不能互相赋值——即在结构化系统里模拟出 Java 那样的名义区分。要求:运行时仍然就是普通 number,零开销。

提示(卡住再展开)

给类型「掺」一个现实中不存在、仅用于区分的标记字段:type UserId = number & { readonly __brand: "UserId" }。两个 brand 字符串不同 → 形状不同 → 不可互换。运行时那个 __brand 从不真正存在(被擦除),所以零开销。这招叫 branded types,是用「故意制造形状差异」去对抗结构化默认行为——理解了 §2.1,这个技巧就是自然推论。