本文只抓一个问题:TypeScript 到底在 JavaScript 之上加了什么?答案不是"类型注解",而是一整套结构化类型系统。理解这个区别,是后续所有文章的前提。

一个反直觉的例子

1
2
3
4
5
6
7
8
9
10
11
12
interface Cat {
name: string;
meow(): void;
}

interface Dog {
name: string;
meow(): void;
}

const dog: Dog = { name: "Buddy", meow() { console.log("woof?"); } };
const cat: Cat = dog; // 编译通过,没有任何警告

Cat 和 Dog 是两个完全不同的 interface,名字不同,语义不同,但 TypeScript 认为它们可以互相赋值。

把同样的事情放到 Java 里:

1
2
3
4
5
interface Cat { String getName(); void meow(); }
interface Dog { String getName(); void meow(); }

Dog dog = new DogImpl();
Cat cat = dog; // 编译错误:不兼容的类型

Java 拒绝了这次赋值。即使方法签名完全一致,Cat 和 Dog 是两个独立声明的接口,它们之间没有继承关系,所以不兼容。

这就是 TypeScript 和 Java 类型系统的根本分歧。Java 用的是名义类型系统(nominal type system)——类型兼容性取决于类型的名字和声明关系。TypeScript 用的是结构化类型系统(structural type system)——类型兼容性取决于类型的结构,即它有哪些属性和方法。

为什么 TypeScript 选择了结构化类型

TypeScript 的设计目标不是发明一门新语言,而是给已有的 JavaScript 代码加上类型检查。JavaScript 本身就是按结构使用对象的:

1
2
3
4
5
6
7
// JavaScript 中没有人关心 response 是什么"类型"
// 只关心它有没有 .json() 方法
async function fetchData(url) {
const response = await fetch(url);
const data = await response.json();
return data;
}

JavaScript 代码天然按"鸭子类型"运作——如果一个对象有 .json() 方法,就可以当作 Response 用,不需要它实现某个接口或继承某个基类。TypeScript 的结构化类型系统是对这种既有行为的形式化:把运行时的隐式结构检查提前到编译期做。

如果 TypeScript 选择了名义类型,现有的 JavaScript 代码几乎无法直接迁移——每个对象字面量都需要声明实现了哪个接口,每个函数参数都需要标注来自哪个类。这会从根本上改变 JavaScript 的编程方式,与"超集"定位矛盾。

类型系统的核心模型:类型是集合

理解 TypeScript 类型系统有一个第一性原理:类型是值的集合。

1
2
3
4
5
6
7
8
9
string   = { "", "hello", "world", "abc", ... }   所有字符串值的集合
number = { 0, 1, -1, 3.14, NaN, Infinity, ... } 所有数字值的集合
boolean = { true, false } 只有两个元素的集合

"hello" = { "hello" } 只有一个元素的集合(字面量类型)
42 = { 42 } 只有一个元素的集合

never = {} 空集,没有任何值属于 never
unknown = 所有可能值的全集

在这个模型下,类型运算就是集合运算:

1
2
3
4
string | number    = string ∪ number     并集:所有字符串 + 所有数字
string & number = string ∩ number 交集:既是字符串又是数字 = never(空集)

"a" | "b" | "c" = { "a", "b", "c" } 三个字面量的并集

赋值兼容性的判定规则也变得直观:如果 A 是 B 的子集,那么 A 类型的值可以赋给 B 类型的变量。

1
2
let x: string | number = "hello"; // "hello" ∈ string ⊂ (string | number),合法
let y: string = x; // x 的类型是 string | number,不是 string 的子集,不合法

这个集合模型是后续所有高级类型特性的基础——联合类型是并集,交叉类型是交集,条件类型是基于子集关系的分支,泛型约束是对集合范围的限制。

类型擦除:编译期存在,运行时消失

TypeScript 的类型信息只存在于编译期。编译器完成类型检查后,输出的 JavaScript 代码中不包含任何类型信息。这个过程叫类型擦除(type erasure)。

1
2
3
4
5
6
7
// TypeScript 源码
function greet(name: string): string {
return `Hello, ${name}`;
}

const user: { name: string; age: number } = { name: "Alice", age: 30 };
greet(user.name);
1
2
3
4
5
6
7
// 编译后的 JavaScript(tsc 输出)
function greet(name) {
return `Hello, ${name}`;
}

const user = { name: "Alice", age: 30 };
greet(user.name);

类型注解、interface、type alias、泛型参数——全部消失了。运行时的 JavaScript 引擎完全不知道 TypeScript 类型系统的存在。

类型擦除带来两个直接的工程影响:

第一,运行时不能用 TypeScript 类型做判断。typeof 在运行时只能区分 JavaScript 的 7 种原始类型和 object/function,无法区分 TypeScript 的 interface 或 type alias。如果需要在运行时验证数据结构(比如 API 返回值),必须借助运行时验证库(Zod、Valibot 等)或手写类型守卫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface User {
name: string;
age: number;
}

// 运行时做不到这一点:
// if (typeof data === "User") { ... } ← 语法不存在

// 必须手写类型守卫:
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"name" in data &&
typeof (data as Record<string, unknown>).name === "string" &&
"age" in data &&
typeof (data as Record<string, unknown>).age === "number"
);
}

第二,TypeScript 的类型检查是零运行时开销的。不像 Python 的 typing 模块在运行时仍保留类型对象,TypeScript 的类型信息在编译后完全消失,不占用任何内存,不影响任何运行时性能。

实验:在 Playground 中观察结构化类型

打开 TypeScript Playground(https://www.typescriptlang.org/play),粘贴以下代码:

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
26
27
interface Point2D {
x: number;
y: number;
}

interface Point3D {
x: number;
y: number;
z: number;
}

function plotPoint(point: Point2D) {
console.log(`(${point.x}, ${point.y})`);
}

const p3: Point3D = { x: 1, y: 2, z: 3 };
plotPoint(p3); // 编译通过

const p2: Point2D = { x: 1, y: 2 };
// plotPoint 期望 Point2D,但 p3(Point3D)也能传入
// 因为 Point3D 的结构包含了 Point2D 的所有属性

// 反过来不行:
function plotPoint3D(point: Point3D) {
console.log(`(${point.x}, ${point.y}, ${point.z})`);
}
// plotPoint3D(p2); // 编译错误:缺少属性 z

观察几件事:

第一,Point3D 可以赋值给 Point2D 参数,因为 Point3D 拥有 Point2D 要求的全部属性(x 和 y)。按集合模型理解:所有 Point3D 值的集合是所有 Point2D 值的集合的子集——每个三维点都是一个有效的二维点(忽略 z),但反过来不成立。

第二,反向赋值报错。Point2D 缺少 z 属性,不满足 Point3D 的结构要求。

第三,切换到 Playground 右侧的 “.JS” 标签页,观察编译输出:所有 interface 声明消失了,函数参数的类型注解消失了,只剩下纯 JavaScript。

多余属性检查:结构化类型的例外

结构化类型的"子集可以赋给超集"规则有一个例外:对象字面量的多余属性检查(excess property checking)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface Config {
host: string;
port: number;
}

// 直接用对象字面量赋值——报错
const config1: Config = {
host: "localhost",
port: 3000,
timeout: 5000 // 编译错误:对象字面量只能指定已知属性
};

// 先赋给中间变量再赋值——不报错
const raw = { host: "localhost", port: 3000, timeout: 5000 };
const config2: Config = raw; // 编译通过

同一个对象,直接用字面量赋值会报错,经过中间变量就不会。这不是 bug,而是 TypeScript 的有意设计。

对象字面量通常是"刚写出来的新对象",如果它包含了目标类型中不存在的属性,很可能是拼写错误(比如把 port 写成 prot)。TypeScript 对这种场景额外收紧检查,在编译期就捕获拼写错误。

而经过中间变量的对象已经有了自己的类型(由推断得到),此时 TypeScript 按标准的结构化兼容性判断——只要中间变量的类型是目标类型的超集,赋值就合法。

这个设计选择体现了 TypeScript 类型系统的实用主义倾向:结构化类型的灵活性和编译期错误检测之间的折中。

TypeScript 编译器做了什么

TypeScript 编译器(tsc)的工作流程可以压成四个阶段:

1
2
3
4
5
6
7
.ts 源码 → 解析(Parse) → 类型检查(Check) → 输出(Emit) → .js + .d.ts
│ │ │
▼ ▼ ▼
构建 AST 遍历 AST 擦除类型
(语法树) 检查类型兼容性 生成 JavaScript
推断缺失类型 生成声明文件
报告类型错误

解析阶段把 TypeScript 源码转换成抽象语法树(AST)。这一步只做语法分析,不涉及类型。

类型检查阶段是核心。编译器遍历 AST,为每个表达式推断类型,检查赋值兼容性、函数调用参数匹配、泛型实例化等。所有的类型错误都在这一步报告。

输出阶段做两件事:一是擦除类型生成 JavaScript 文件(.js),二是提取类型信息生成声明文件(.d.ts)。声明文件用于给其他 TypeScript 项目或 IDE 提供类型信息,自身不包含任何可执行代码。

截至 TypeScript 6.0(2026-03),编译器仍然用 JavaScript/TypeScript 实现。TypeScript 7.0(2026-07 GA)将编译器用 Go 重写,类型检查速度提升约 10 倍。但无论实现语言怎么变,上面四个阶段的逻辑结构不变。

TypeScript 的版本演进:从 5.x 到 7.0

截至 2026 年 7 月,TypeScript 的版本线经历了几个重要节点:

TypeScript 5.8/5.9 是日常开发中的主力版本。5.8 增加了 erasableSyntaxOnly 编译选项,禁止 enum、namespace 等 TypeScript 独有的运行时语法,推动项目向"纯类型注解"方向发展。5.9 稳定了 TC39 Decorator Metadata 提案,增加了 import defer 语法支持和 --module node20 模式。

TypeScript 6.0(2026-03 稳定)是 JavaScript 实现的最终版本。它的主要作用是为 7.0 迁移做准备:strict 默认开启、target 默认升到 es2025moduleResolution node10 被标记废弃、target: es5 被标记废弃。6.0 还引入了 --stableTypeOrdering 标志,帮助开发者提前发现 6.0 和 7.0 之间类型排序差异导致的输出变化。

TypeScript 7.0(2026-07-08 GA)是 Go 原生编译器。核心变化是编译速度提升 8-12 倍(VS Code 代码库的类型检查从 125 秒降到 10 秒),内存占用降低 3 倍。Go 重写的架构决策是:不重新设计类型检查算法,而是逐文件将已有的 JavaScript 实现移植到 Go,保持相同的语义。性能提升来自两个叠加效应——原生机器码替代解释执行,加上 Go goroutine 实现的并行类型检查。

这个系列的代码示例默认基于 TypeScript 5.9/6.0 的行为。涉及 7.0 特有变化时会明确标注版本。

模式提炼

TypeScript 的结构化类型系统可以提炼为一个通用设计模式:契约由结构定义,而非由名字定义。

在名义类型系统中,两个类型的兼容性取决于它们是否显式声明了继承或实现关系。这像法律系统中的"身份认证"——不看你能做什么,只看你的证件。

在结构化类型系统中,两个类型的兼容性取决于它们是否具有相同的属性和方法签名。这像面试中的"能力评估"——不看你从哪毕业,只看你能不能完成任务。

这两种方式各有适用场景。名义类型在需要严格区分不同领域概念时更安全(比如"用户 ID"和"订单 ID"都是 string,但不应该互相赋值)。结构化类型在需要跨边界组合对象时更灵活(比如 API 返回的 JSON 对象不需要声明实现了哪个接口就能直接使用)。

TypeScript 选择结构化类型作为默认,同时通过品牌类型(branded types)等模式支持名义类型的使用场景,是一种"默认灵活、按需收紧"的策略。

工程迁移表

TypeScript 概念 Java 对应 Rust 对应 Haskell 对应 核心差异
结构化类型(structural typing) 无直接对应;Java 是名义类型 trait 实现(名义 + 结构混合) type class(名义) TS 按结构判断兼容性,其余按名字/声明
interface interface(名义) trait(名义,需显式 impl) type class(名义,需显式 instance) TS 的 interface 不需要 implements 声明
type alias 无直接对应(Java 没有类型别名) type alias(编译期展开) type synonym(编译期展开) TS 的 type alias 支持联合、交叉、条件等高级运算
类型擦除 泛型类型擦除(保留原始类型) 无擦除(单态化) 无擦除(字典传递) TS 擦除所有类型信息,Java 只擦除泛型参数
unknown Object(运行时仍有类型) 无直接对应 无直接对应 TS 的 unknown 是编译期概念,运行时不存在
any 无对应(Java 没有"关闭类型检查"的机制) 无对应 无对应 any 是 TypeScript 的逃生舱,关闭类型检查
never 无对应(Java 用 void 或异常表达类似语义) !(never type,nightly) Void(空类型) TS 的 never 是空集,用于穷尽检查

常见误解

误解一:TypeScript 是 JavaScript 的一种方言。

修正:TypeScript 是 JavaScript 的超集——每一段合法的 JavaScript 代码都是合法的 TypeScript 代码。TypeScript 没有改变 JavaScript 的运行时语义,只是在编译期增加了类型检查层。编译后输出的就是标准 JavaScript。

误解二:TypeScript 的类型检查会让代码变慢。

修正:类型擦除意味着运行时完全没有类型检查的开销。TypeScript 的类型系统是零运行时成本的。代价出现在开发期:编译器需要时间做类型检查。TypeScript 7.0 的 Go 原生编译器将这个开发期成本降低了约 10 倍。

误解三:TypeScript 的 interface 和 Java 的 interface 是一回事。

修正:Java 的 interface 是一种名义契约——类必须显式声明 implements SomeInterface 才能被视为该接口的实现。TypeScript 的 interface 是一种结构契约——任何拥有匹配结构的对象自动满足该 interface,无需任何声明。

误解四:用了 TypeScript 就不需要运行时数据验证了。

修正:TypeScript 的类型检查发生在编译期,只能约束编译期已知的代码。外部数据(API 响应、用户输入、数据库查询结果、JSON 解析结果)在编译期是未知的,TypeScript 无法保证这些数据符合预期类型。运行时验证(Zod、Valibot 等)仍然是必要的补充。

误解五:anyunknown 差不多,都是"任意类型"。

修正:any 关闭类型检查——对 any 类型的值可以做任何操作,编译器不会报错。unknown 保持类型检查——可以把任何值赋给 unknown 变量,但在使用之前必须通过类型收窄(typeof、instanceof 等)证明它的实际类型。unknown 是类型安全的,any 不是。

练习

练习 1:结构化类型兼容性判断

在 TypeScript Playground(https://www.typescriptlang.org/play)中预测以下代码哪些行会报编译错误,然后验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface Shape {
area(): number;
}

interface Circle {
radius: number;
area(): number;
}

interface Rectangle {
width: number;
height: number;
area(): number;
}

declare const circle: Circle;
declare const rect: Rectangle;
declare const shape: Shape;

const s1: Shape = circle; // ?
const s2: Shape = rect; // ?
const c1: Circle = shape; // ?
const c2: Circle = rect; // ?

练习 2:类型擦除观察

在 TypeScript Playground 中写一段使用泛型的代码,切换到 “.JS” 标签页观察编译输出。泛型参数在输出中还存在吗?类型约束呢?

1
2
3
4
5
6
function identity<T extends { id: number }>(item: T): T {
console.log(item.id);
return item;
}

const result = identity({ id: 1, name: "Alice" });

练习 3:多余属性检查边界

以下三种写法中,哪些会触发多余属性检查,哪些不会?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface Options {
url: string;
method: "GET" | "POST";
}

// 写法 A
const a: Options = { url: "/api", method: "GET", retry: 3 };

// 写法 B
const raw = { url: "/api", method: "GET" as const, retry: 3 };
const b: Options = raw;

// 写法 C
function request(options: Options) { /* ... */ }
request({ url: "/api", method: "GET", retry: 3 });

系列导航

序号 主题 状态
00 导读:TypeScript 不是给 JavaScript 加注解,是给 JavaScript 加类型系统 本文
01 类型是集合:理解 TypeScript 类型系统的第一性原理 待写
02 类型收窄:从 unknown 到具体类型的检查路径 待写
03 类型推断:编译器怎样猜出你没写的类型 待写
04 结构化类型:鸭子类型的形式化 待写

参考资料