No history yet

结构化类型系统

结构化类型系统

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),即 Point3DPoint2D 的一个子类型。

模拟标称类型

结构化类型非常灵活,但在某些情况下,这种灵活性可能导致问题。例如,假设我们用 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 的结构化类型系统中,UserIdOrderId 都只是 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,因为:

  1. employee拥有user所需的name属性。
  2. employee拥有user所需的address属性。
  3. employee.address的形状(包含street, city, zipCode)也包含了user.address所需的形状(street, city)。

这种深层检查是 TypeScript 静态分析能力强大的关键。它使得我们可以放心地处理复杂的数据结构,因为编译器会不知疲倦地验证每一层嵌套的兼容性,确保代码的稳健性。

Quiz Questions 1/5

在 TypeScript 中,确定两种类型是否兼容的主要依据是什么?

Quiz Questions 2/5

考虑以下 TypeScript 代码。哪项赋值操作会成功?

interface Vehicle {
  wheels: number;
}

interface Car {
  wheels: number;
  engineType: string;
}

let myVehicle: Vehicle;
let myCar: Car = { wheels: 4, engineType: 'V8' };