TypeScript 进阶实战指南
结构化类型系统
结构化类型系统
JavaScript 开发者经常提到一个概念:“如果它走起路来像鸭子,叫起来也像鸭子,那它就是一只鸭子。”这被称为鸭子类型(Duck Typing)。TypeScript 将这一动态概念形式化,并融入其静态类型检查中,形成了其核心的类型系统:结构化类型系统(Structural Typing)。
在结构化类型系统中,类型的兼容性取决于它们的“形状”(shape),而不是它们的显式名称。
简单来说,只要一个对象的结构——即它拥有的属性和方法,以及这些成员的类型——与另一个类型所要求的结构相匹配,TypeScript 就认为它们是兼容的。这与 Java 或 C# 等语言中使用的(Nominal Typing)形成鲜明对比,后者要求类型必须有完全相同的名称才能兼容。
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
let p2: Point2D = { x: 10, y: 20 };
let p3: Point3D = { x: 5, y: 15, z: 25 };
// 这是允许的,因为 p3 拥有 p2 所需的所有属性
// p3 的“形状”包含了 p2 的“形状”
p2 = p3;
// 这是不允许的,因为 p2 缺少 p3 所需的 z 属性
// p3 = p2; // Error: Property 'z' is missing in type 'Point2D'
上面的例子展示了类型兼容性的核心原则:一个类型如果拥有另一个类型的所有必需属性,并且这些属性的类型也兼容,那么它就可以被赋值给另一个类型。Point3D 的实例之所以能赋值给 Point2D 类型的变量,是因为 Point3D 的结构包含了 Point2D 的所有要求。这在面向对象编程中也称为子类型关系(subtyping),即 Point3D 是 Point2D 的一个子类型。
模拟标称类型
结构化类型非常灵活,但在某些情况下,这种灵活性可能导致问题。例如,假设我们用 number 类型来表示用户 ID 和订单 ID。
type UserId = number;
type OrderId = number;
function processOrder(orderId: OrderId) { /* ... */ }
const userId: UserId = 123;
const orderId: OrderId = 456;
// 这是合法的,但逻辑上是错误的!
// TypeScript 只看到两个 number 类型,认为它们兼容
processOrder(userId);
从逻辑上讲,将一个 UserId 传递给一个期望 OrderId 的函数是错误的。但在 TypeScript 的结构化类型系统中,UserId 和 OrderId 都只是 number 的别名,它们的“形状”完全相同。因此,TypeScript 无法在编译时捕获这个错误。
为了解决这个问题,我们可以引入一个独特的属性来“标记”或“品牌化”我们的类型,从而在结构上区分它们。这通常通过交叉类型(&)和一个独特的、从未实际使用的属性来实现。这种技术被称为(Branded Types)。
// 定义一个通用的品牌结构
type Brand<K, T> = K & { __brand: T };
// 创建品牌化的类型
type UserId = Brand<number, "UserId">;
type OrderId = Brand<number, "OrderId">;
function processOrder(orderId: OrderId) { /* ... */ }
// 我们需要类型断言来创建品牌化类型的值
const userId = 123 as UserId;
const orderId = 456 as OrderId;
// 现在,TypeScript 会捕获这个错误!
// 因为 UserId 和 OrderId 的 __brand 属性不同
processOrder(userId); // Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
通过添加一个__brand属性,我们改变了类型的“形状”。UserId 的形状现在是 number & { __brand: "UserId" },而 OrderId 的形状是 number & { __brand: "OrderId" }。因为它们的 __brand 属性不兼容,TypeScript 现在可以正确地识别出类型错误,从而在保持大部分灵活性的同时,为关键部分增加了严格的类型安全性。
深层结构推断
TypeScript 的结构化类型检查不仅仅停留在顶层属性。它会递归地检查整个对象树的结构。当你比较两个具有嵌套对象的类型时,TypeScript 会深入到每一个嵌套层级,确保它们的形状兼容。
interface UserProfile {
name: string;
address: {
street: string;
city: string;
};
}
interface EmployeeProfile {
name: string;
employeeId: number;
address: {
street: string;
city: string;
zipCode: string;
};
}
let user: UserProfile;
let employee: EmployeeProfile = {
name: "Alice",
employeeId: 99,
address: {
street: "123 Main St",
city: "Anytown",
zipCode: "12345"
}
};
// 这是允许的
// 顶层属性 'name' 兼容
// 嵌套属性 'address' 也兼容,因为 employee.address 包含了 user.address 所需的所有属性
user = employee;
在这个例子中,employee可以赋值给user,因为:
employee拥有user所需的name属性。employee拥有user所需的address属性。employee.address的形状(包含street,city,zipCode)也包含了user.address所需的形状(street,city)。
这种深层检查是 TypeScript 静态分析能力强大的关键。它使得我们可以放心地处理复杂的数据结构,因为编译器会不知疲倦地验证每一层嵌套的兼容性,确保代码的稳健性。
在 TypeScript 中,确定两种类型是否兼容的主要依据是什么?
考虑以下 TypeScript 代码。哪项赋值操作会成功?
interface Vehicle {
wheels: number;
}
interface Car {
wheels: number;
engineType: string;
}
let myVehicle: Vehicle;
let myCar: Car = { wheels: 4, engineType: 'V8' };