类型系统基础与心智模型
类型系统基础与心智模型
写在前面
会用 TypeScript 的人很多,但理解 TypeScript 类型系统设计哲学的人不多。
这一篇不讲 interface 怎么写、type 怎么用——这些一查文档就有。本篇讲根本问题:TypeScript 的类型到底是什么?为什么这样设计?什么时候它保护你,什么时候它坑你?
理解了这层心智模型,后面的联合类型、泛型、条件类型都是自然延伸。
一、类型是什么
1.1 一个常见的误解
很多新手觉得:“TypeScript 的类型就是给变量加约束,加错了会报错”。
这个理解是片面的。
TypeScript 的类型实际上是对值的描述——它告诉你"这个值长什么样",而不是"这个值必须做什么"。
function greet(name: string) {
console.log(`Hello, ${name}`);
}
greet("World"); // OK
greet(42); // 报错:Argument of type 'number' is not assignable to parameter of type 'string'
看起来"类型就是约束"对吧?但往下看:
type User = {
name: string;
age: number;
};
function celebrate(user: User) {
console.log(`Happy ${user.age}th birthday, ${user.name}!`);
}
const alice = { name: "Alice", age: 30, hobby: "reading" };
celebrate(alice); // OK,虽然 hobby 不在 User 里
TypeScript 不要求传入的对象"长得完全像 User",只要"结构兼容"。这跟 Java、C# 完全不一样。
1.2 类型的两个作用
类型在 TypeScript 里扮演两个角色:
- 约束(Constraint):在编译期发现错误
- 文档(Documentation):让代码更易读、易理解、易重构
// 没有类型 - 你不知道这个函数期望什么
function processUser(user) {
return user.name.toUpperCase();
}
// 有类型 - 一目了然
interface User {
id: string;
name: string;
age: number;
}
function processUser(user: User): string {
return user.name.toUpperCase();
}
类型是给读代码的人看的,也是给 IDE 看的。当你接手一个老项目,TypeScript 类型就是最准确的文档——比 README 准确,比注释不会过期。
二、结构化类型 vs 名义类型
2.1 两种类型系统
| 维度 | 结构化类型(Structural) | 名义类型(Nominal) |
|---|---|---|
| 判断兼容 | 看结构(字段) | 看名字(类型) |
| 语言 | TypeScript、Go、Python(部分) | Java、C#、C++ |
| 灵活度 | 高 | 低 |
| 严格度 | 低 | 高 |
2.2 例子对比
// TypeScript: 结构化
type User = { name: string; age: number };
type Employee = { name: string; age: number };
const e: Employee = { name: "Bob", age: 25 };
const u: User = e; // OK - 结构兼容
// Java 风格的"严格"方式
type User = { name: string; age: number; __brand: "User" };
type Employee = { name: string; age: number; __brand: "Employee" };
const e: Employee = { name: "Bob", age: 25, __brand: "Employee" };
const u: User = e; // 报错 - 不同的 __brand
TypeScript 是结构化类型——只要字段匹配就能赋值。
2.3 结构化类型的优劣
优势:
- 灵活,不需要太多类型定义
- duck typing 的好处(像 Python)
- 跟 JavaScript 行为一致
劣势:
- 容易意外兼容(看起来没关系但能赋值)
- 难以表达"名义上的不同"
2.4 实战:结构化类型带来的坑
// 第三方库的"凭证"类型
type Credentials = {
username: string;
password: string;
};
// 我自己定义的"用户"类型
type User = {
username: string; // 巧合字段名一样
password: string;
};
// 看起来没问题?
function login(c: Credentials) {
return api.login(c);
}
const user: User = getUser();
login(user); // 编译通过! 但语义上不对
结构化类型有时候太宽容——User 和 Credentials 字段一样就能互换,但语义完全不同。
解决:用 brand types(后面讲)。
三、协变与逆变
3.1 什么是协变和逆变
当类型 A 是 B 的子类型(A extends B)时,考虑容器类型 Container<A> 和 Container<B> 的关系。
| 术语 | 关系 | 适用场景 |
|---|---|---|
| 协变(Covariant) | Container<A> 是 Container<B> 的子类型 |
输出位置(返回值) |
| 逆变(Contravariant) | Container<B> 是 Container<A> 的子类型 |
输入位置(参数) |
| 不变(Invariant) | 没有子类型关系 | 读写都有的位置 |
| 双变(Bivariant) | 同时是协变和逆变 | 特殊场景 |
3.2 函数的协变/逆变
// 返回值:协变
// 如果 A extends B,那么 () => A 是 () => B 的子类型
// (因为调用者期待 B,实际返回 A 也行)
type Animal = { name: string };
type Dog = Animal & { bark(): void };
// 返回 Dog 的函数,可以赋值给返回 Animal 的函数
const dogFactory: () => Dog = () => ({ name: "旺财", bark: () => {} });
const animalFactory: () => Animal = dogFactory; // OK
// 参数:逆变
// 如果 A extends B,那么 (b: B) => void 是 (a: A) => void 的子类型
// (因为参数要求更宽,实际能处理更窄的类型)
type Animal = { name: string };
type Dog = Animal & { bark(): void };
// 接受 Animal 的函数,可以赋值给接受 Dog 的函数
const handleAnimal: (a: Animal) => void = (a) => console.log(a.name);
const handleDog: (d: Dog) => void = handleAnimal; // OK
// 反过来不行
const handleDogOnly: (d: Dog) => void = (d) => d.bark();
const handleAnimalWrong: (a: Animal) => void = handleDogOnly; // 报错!
为什么参数是逆变? 因为函数可以"承诺"更少的输入类型(更宽),但不能"要求"更多的输入类型(更窄)。
// 实际:handleAnimal 可以处理任何 Animal,包括 Dog
// 但 handleDogOnly 只能处理 Dog
// 如果把 handleDogOnly 传给期待 handleAnimal 的地方
// 调用者传入 Cat(Animal 子类),handleDogOnly 会尝试调用 bark() 而出错
3.3 TypeScript 的实际行为
TypeScript 4.x 之前参数是双变的(更宽松),4.x 之后是严格逆变(strictFunctionTypes)。
// tsconfig.json
{
"compilerOptions": {
"strictFunctionTypes": true // 默认 true,推荐
}
}
3.4 实战:协变逆变的真实例子
interface EventEmitter<T> {
on(event: string, listener: (data: T) => void): void;
emit(event: string, data: T): void;
}
// stringEmitter: EventEmitter<string>
const stringEmitter: EventEmitter<string> = ...;
// 协变 - 安全
const anyEmitter: EventEmitter<any> = stringEmitter;
// 逆变 - 注意参数
const numberEmitter: EventEmitter<number> = stringEmitter;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 报错: Type 'EventEmitter<string>' is not assignable to 'EventEmitter<number>'
// 因为 listener 是 (data: string) => void,不能安全地传给 (data: number) => void
四、双变(bivariance)
4.1 什么是双变
TypeScript 早期(4.0 前)对方法(method shorthand)的参数检查是双变的:
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
// 方法写法
interface Comparer {
compare(a: Animal): number; // 双变 - 早期
}
// 函数属性写法
interface Comparer2 {
compare: (a: Animal) => number; // 严格逆变 - 4.0+
}
const dogComparer: Comparer = {
compare: (d: Dog) => d.bark().length // 双变允许
};
const dogComparer2: Comparer2 = {
compare: (d: Dog) => d.bark().length // 报错!严格逆变
};
为什么 TypeScript 早期用双变? 简单(减少类型注解的繁琐),代价是不够严格。
五、readonly 和可变性的协变
5.1 readonly 是协变
type Mutable = { x: number };
type Readonly = { readonly x: number };
const m: Mutable = { x: 1 };
const r: Readonly = m; // OK - Mutable 是 Readonly 的子类型
// 反过来不行
const m2: Mutable = r; // 报错 - Readonly 不能赋值给 Mutable
readonly 让类型更"宽"——只读比可写更宽松。
5.2 数组的协变
TypeScript 的数组是协变的——这跟"严格"类型系统不一样。
// 协变
const animals: Animal[] = [];
const dogs: Dog[] = [];
animals = dogs; // OK
为什么这样设计? 跟 JavaScript 的行为一致(JS 数组就是对象,duck typing)。
但这有运行时不安全的风险:
animals.push(new Cat()); // TypeScript 报错
// ^^^^
// Type 'Cat' is missing the following properties from type 'Dog': bark
TypeScript 阻止了错误——因为 animals: Animal[] 的 push 参数期望 Animal,传入 Cat 不行(协变下,Dog[] 是 Animal[] 的子类型,但写操作会被检查)。
六、TypeScript vs Java 类型系统对比
| 维度 | TypeScript | Java |
|---|---|---|
| 类型系统 | 结构化 | 名义 |
| 泛型协变 | 默认不变 | 协变(数组) / 不变(集合) |
| 函数参数 | 严格逆变(strictFunctionTypes) | 逆变 |
| 类型擦除 | 编译时存在,运行时不存 | 编译时存在,运行时不存(泛型) |
| 运行时类型 | 有(JS 本身) | 没有 |
| 编译产物 | JS | JVM 字节码 |
TypeScript 跟 Java 的"看着像"完全是两码事——很多 Java 转 TS 的人会踩坑,比如"两个不同的类即使字段一样也不能互相赋值"(Java 行为),但 TypeScript 就可以。
七、TypeScript 的"特殊"类型
7.1 any
let x: any = 1;
x.foo.bar.baz; // 编译通过,运行时报错
any 关闭了类型检查——等于回到 JavaScript。
用 any 等于放弃了 TypeScript 的好处。尽量用 unknown。
7.2 unknown
let x: unknown = 1;
x.foo; // 报错:Object is of type 'unknown'
// 必须先 narrow(收窄)
if (typeof x === "string") {
x.toUpperCase(); // OK,这里 x 是 string
}
unknown 是类型安全的 any——用之前必须 narrow。
7.3 never
function throwError(): never {
throw new Error();
}
function loopForever(): never {
while (true) {}
}
never 是"没有任何值"的类型——用于:
- 抛异常的函数返回值
- 死循环的函数
- 不可能的情况(exhaustiveness check)
type Shape = "circle" | "square";
function area(s: Shape) {
switch (s) {
case "circle": return ...;
case "square": return ...;
default:
// 这里的 s 是 never,如果漏了 case,这里会报错
const _exhaustive: never = s;
}
}
7.4 void
function log(): void {
console.log("hi");
}
void 表示"没有有意义的返回值"。undefined 是 void 的子类型。
八、实战:用类型改善代码
8.1 改前:JS 风格
function fetchUser(id, callback) {
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => callback(null, data))
.catch(err => callback(err));
}
fetchUser("123", (err, user) => {
if (err) {
// 处理错误
}
console.log(user.name); // user 是什么类型?
});
8.2 改后:TS 风格
interface User {
id: string;
name: string;
email: string;
age: number;
}
type Result<T> =
| { success: true; data: T }
| { success: false; error: Error };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
const data: User = await res.json();
return { success: true, data };
} catch (error) {
return { success: false, error: error as Error };
}
}
const result = await fetchUser("123");
if (result.success) {
// 这里 result.data 是 User 类型
console.log(result.data.name);
} else {
// 这里 result.error 是 Error 类型
console.error(result.error);
}
类型让代码更安全,也更易读。
九、常见心智误区
9.1 误区 1:“类型越多越好”
错。类型是工具,不是越多越好。
// 过度类型化
type GetUserRequest = {
type: "GET_USER";
payload: {
id: string;
includeProfile: boolean;
includeOrders: boolean;
};
};
type GetOrderRequest = { ... };
type Request = GetUserRequest | GetOrderRequest | ...; // 越来越长
如果类型太复杂,重构比写起来更痛苦。
9.2 误区 2:“类型 = 运行时检查”
错。TypeScript 类型在编译后完全消失——JavaScript 运行时没有任何类型信息。
function greet(name: string) { }
// 编译后
function greet(name) { } // 没有任何类型
如果你需要运行时验证,需要额外的库(zod、yup、io-ts、valibot)。
9.3 误区 3:“any 能解决一切”
any 是"我放弃了类型检查"的意思。用 unknown 替代。
9.4 误区 4:“TS 严格模式越严越好”
strict: true 是好的,但**noImplicitAny、strictNullChecks 等要按团队能力逐步打开**。
新项目:strict: true。
老项目:逐步打开,不要一步到位。
十、本篇小结
| 概念 | 关键点 |
|---|---|
| 结构化类型 | 看结构不看名字,TypeScript 的核心 |
| 协变 | 子类型 → 容器子类型(输出) |
| 逆变 | 子类型 → 父类型 → 容器父类型(输入) |
| 双变 | TS 早期参数行为,4.0+ 严格逆变 |
| any | 关闭类型检查,慎用 |
| unknown | 类型安全的 any |
| 心智模型 | 理解 |
|---|---|
| 类型是约束 | ✅ 但也是文档 |
| 类型越多越好 | ❌ 越简单越好 |
| 类型 = 运行时检查 | ❌ 类型运行时不存在 |
下一篇讲 联合类型与类型守卫——把"业务里"用得最多的 TS 模式讲透。