TypeScript 类型系统的结构化原理
结构类型系统(Structural Type System)
TypeScript 使用结构类型系统(也称"鸭子类型"),而非名义类型系统(Nominal Type System)。这意味着:类型兼容性由类型的结构(属性形状)决定,而非类型的名称或声明来源。
结构类型系统(Structural Typing)
如果类型 A 具有类型 B 所需的所有属性,则 A 兼容 B——即使 A 和 B 从未有过显式的继承关系。TypeScript、Go 使用结构类型。例:一个有 name: string 和 age: number 的对象,可以赋值给要求 { name: string } 的变量,即使两者类型名称不同。
名义类型系统(Nominal Typing)
即使两个类型结构完全相同,如果名称不同,它们也不兼容。Java、C# 使用名义类型。在这类语言中,两个名为 Cat 和 Dog 的类,即使都有 name: string,也不能互相赋值,必须显式继承才能兼容。
多余属性检查(Excess Property Check)
TypeScript 有一个例外:将对象字面量直接赋给有类型标注的变量时,会触发多余属性检查,不允许有接口未声明的属性。但将已赋值的变量(非字面量)传递时,不触发此检查。这是结构类型系统的特殊规则,用于防止常见的拼写错误。
interface Named { name: string; }
// 结构类型兼容性:无需显式声明 implements
const user = { name: '张三', age: 28 };
const named: Named = user; // ✅ user 有 name 属性,结构兼容
// 多余属性检查(对象字面量直接赋值时触发)
const n1: Named = { name: '李四', age: 30 }; // ❌ 多余属性 age 报错
const n2: Named = user; // ✅ 通过变量传递,不触发检查
// 函数参数同样适用:函数参数兼容更宽松(协变/逆变规则)
function greet(n: Named) { console.log(n.name); }
greet({ name: '王五', email: 'w@a.com' }); // ❌ 字面量触发多余属性检查
greet(user); // ✅ 变量传递,不触发
常见误区:多余属性检查只针对字面量
很多开发者以为 TypeScript 不允许对象有接口未声明的属性,这是误解。TypeScript 的结构类型系统允许"超集"的赋值。多余属性检查只在对象字面量直接赋值给类型标注的位置时触发(函数调用时字面量参数、直接变量赋值等),目的是捕捉拼写错误,而非强制"精确"匹配。
interface:定义对象形状
基本 interface 语法
interface User {
id: number;
name: string;
email: string;
age?: number; // 可选属性
readonly createdAt: Date; // 只读属性
[key: string]: unknown; // 索引签名(允许任意额外属性)
}
// 函数类型接口
interface Formatter {
(value: string, options?: { uppercase?: boolean }): string;
}
// 构造函数类型接口
interface Constructor<T> {
new (...args: any[]): T;
}
接口继承
interface Animal {
name: string;
age: number;
}
interface Pet extends Animal {
owner: string;
}
// 多接口继承
interface ServiceDog extends Pet, Serializable {
serviceType: string;
}
声明合并(Declaration Merging)
// interface 的独特特性:多次声明会自动合并
interface Window {
myCustomProperty: string;
}
// 现在 window.myCustomProperty 是合法的(常用于扩展全局类型)
interface Config { host: string; }
interface Config { port: number; }
// 合并结果等同于:interface Config { host: string; port: number; }
type 别名
type 的用途
// 对象类型
type Point = { x: number; y: number };
// 联合类型(interface 无法做到)
type StringOrNumber = string | number;
// 元组(interface 无法做到)
type Pair<T> = [T, T];
// 从其他类型派生
type UserKeys = keyof User; // 'id' | 'name' | 'email' | ...
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// 条件类型(interface 无法做到)
type IsString<T> = T extends string ? true : false;
interface vs type:何时用哪个?
| 特性 | interface | type |
|---|---|---|
| 对象形状 | ✅ | ✅ |
| 声明合并 | ✅(自动合并) | ❌(不支持) |
| 联合/交叉类型 | ❌ | ✅ |
| 映射类型 | ❌ | ✅ |
| 条件类型 | ❌ | ✅ |
| implements(类实现) | ✅ | ✅(对象类型) |
| 错误信息可读性 | 通常更简洁 | 复杂类型时较长 |
实践建议
通用原则:定义对象形状(特别是公共 API)时用 interface,因为它支持声明合并(方便第三方扩展),错误信息也更清晰;需要联合、条件、映射等高级类型时用 type。在同一个代码库中,选定一种风格并保持一致即可——两者都正确。
interface 与 type 的深层区别
声明合并(Declaration Merging)
interface 的独有特性:同名 interface 会自动合并属性。这是 TypeScript 设计用于扩展第三方库类型(如扩展 Window、Express.Request)的机制。type 别名不支持重复声明,会报"Duplicate identifier"错误。
类型别名的惰性求值
type 别名支持递归类型定义(如 JSON 类型),因为 type 是惰性求值的。interface 在某些递归场景下也支持,但行为略有不同——interface 的名称在定义内部就已绑定,type 在展开时才求值。
错误信息中的表现
interface 类型在错误信息中通常以名称显示(如 "Type 'X' is not assignable to type 'User'");复杂的 type 别名(特别是映射类型)可能被展开显示,错误信息更长更难读。对公共 API 优先用 interface,错误更友好。
// 递归类型:JSON 值的类型定义
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[] // 递归:数组元素也是 JSONValue
| { [key: string]: JSONValue }; // 递归:对象值也是 JSONValue
// 使用:可以类型安全地处理任意 JSON 数据
const data: JSONValue = {
name: '张三',
scores: [95, 87, 92],
address: {
city: '北京',
zip: null
}
}; // ✅ 深层嵌套结构
接口高级特性
索引签名(Index Signatures)
索引签名允许对象拥有任意数量的属性,只要键和值类型符合约束:
interface Dictionary<T> {
[key: string]: T;
}
const scores: Dictionary<number> = { alice: 95, bob: 87 };
// 已知属性必须与索引签名的值类型兼容(是其子集)
interface Config {
[key: string]: string | number; // 索引签名允许 string 或 number
host: string; // ✅ string 是 string|number 的子集
port: number; // ✅
// enabled: boolean ❌ boolean 不是 string|number 的子集
}
交叉类型(Intersection Types)
type User = { id: number; name: string };
type Admin = { adminLevel: number; permissions: string[] };
// AdminUser 必须同时满足 User 和 Admin 的所有约束
type AdminUser = User & Admin;
// 实用场景:为函数参数注入额外属性
function withTimestamp<T>(data: T): T & { createdAt: Date } {
return { ...data, createdAt: new Date() };
}
// 注意:交叉冲突的属性会产生 never 类型
type Conflict = { id: string } & { id: number };
// Conflict.id 的类型是 string & number = never
// 意味着没有任何值可以赋给 Conflict.id
扩展全局类型
// 扩展 Express Request(为 req.user 添加类型)
declare namespace Express {
interface Request {
user?: { id: string; email: string; role: string };
}
}
// 扩展浏览器全局 Window
declare global {
interface Window {
analytics: { track: (event: string) => void };
}
}
// 之后 window.analytics.track('page_view') 是类型安全的
本章小结
本章核心要点
- interface 的独特功能:声明合并(多次声明自动合并);适合扩展第三方类型(Window、Express.Request);公共 API 对象形状首选。
- type 的独特功能:联合类型(A | B)、交叉类型(A & B)、条件类型、映射类型、元组;不支持声明合并。
- 索引签名:允许任意额外属性;已知属性值类型必须与索引签名兼容(是其子集),否则编译报错。
- 交叉类型 & 的冲突:当两个类型有同名但不同类型的属性时,交叉后该属性类型变为 never(不可赋值),需注意避免。
- 实践原则:对象形状和类实现接口用 interface;联合/条件等复合类型用 type;项目内保持风格一致。