10.结构化类型系统:类型兼容性判断的幕后

10/9/2023

在 TypeScript 中,你可能遇见过以下这样“看起来不太对,但竟然能正常运行”的代码:

class Cat {
  eat() { }
}

class Dog {
  eat() { }
}

function feedCat(cat: Cat) { }

feedCat(new Dog())
1
2
3
4
5
6
7
8
9
10
11

这里的 feedCat 函数明明需要的是一只猫,可为什么上传一只狗也可以呢?实际上,这就是 TypeScript 的类型系统特性:结构化类型系统,也是我们这一节要学习的概念。我们会了解结构化类型系统的比较方式,对比另一种类型系统(标称类型系统)的工作方式,以及在 TypeScript 中去模拟另一种类型系统。

结构化类型系统的概念非常基础但十分重要,它不仅能让你明确类型比较的核心原理,从根上理解条件类型等类型工具,也能够在日常开发中帮你解决许多常见的类型报错。

本节代码见:Structual Type System (opens new window)

# 结构化类型系统

首先回到我们开头提出的问题,如果我们为 Cat 类新增一个独特的方法,这个时候的表现才是符合预期的,即我们只能用真实的 Cat 类来进行调用:

class Cat {
  meow() { }
  eat() { }
}

class Dog {
  eat() { }
}

function feedCat(cat: Cat) { }

// 报错!
feedCat(new Dog())
1
2
3
4
5
6
7
8
9
10
11
12
13

这是因为,TypeScript 比较两个类型并非通过类型的名称(即 feedCat 函数只能通过 Cat 类型调用),而是比较这两个类型上实际拥有的属性与方法。也就是说,这里实际上是比较 Cat 类型上的属性是否都存在于 Dog 类型上。

在我们最初的例子里,Cat 与 Dog 类型上的方法是一致的,所以它们虽然是两个名字不同的类型,但仍然被视为结构一致,这就是结构化类型系统的特性。你可能听过结构类型的别称鸭子类型(*Duck Typing*),这个名字来源于鸭子测试(*Duck Test*)。其核心理念是,如果你看到一只鸟走起来像鸭子,游泳像鸭子,叫得也像鸭子,那么这只鸟就是鸭子

也就说,鸭子类型中两个类型的关系是通过对象中的属性方法来判断的。比如最开始的 Cat 类型和 Dog 类型被视为同一个类型,而为 Cat 类型添加独特的方法之后就不再能被视为一个类型。但如果为 Dog 类型添加一个独特方法呢?

class Cat {
  eat() { }
}

class Dog {
  bark() { }
  eat() { }
}

function feedCat(cat: Cat) { }

feedCat(new Dog())
1
2
3
4
5
6
7
8
9
10
11
12

这个时候为什么却没有类型报错了?这是因为,结构化类型系统认为 Dog 类型完全实现了 Cat 类型。至于额外的方法 bark,可以认为是 Dog 类型继承 Cat 类型后添加的新方法,即此时 Dog 类可以被认为是 Cat 类的子类。同样的,面向对象编程中的里氏替换原则也提到了鸭子测试:如果它看起来像鸭子,叫起来也像鸭子,但是却需要电池才能工作,那么你的抽象很可能出错了。

更进一步,在比较对象类型的属性时,同样会采用结构化类型系统进行判断。而对结构中的函数类型(即方法)进行比较时,同样存在类型的兼容性比较:

class Cat {
  eat(): boolean {
    return true
  }
}

class Dog {
  eat(): number {
    return 599;
  }
}

function feedCat(cat: Cat) { }

// 报错!
feedCat(new Dog())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

对于独立函数类型的进一步比较,我们会在后面的「函数类型的比较:类型系统中的协变与逆变」一节中深入讲解。

这就是结构化类型系统的核心理念,即基于类型结构进行判断类型兼容性。结构化类型系统在 C#、Python、Objective-C 等语言中都被广泛使用或支持。

严格来说,鸭子类型系统和结构化类型系统并不完全一致,结构化类型系统意味着基于完全的类型结构来判断类型兼容性,而鸭子类型则只基于运行时访问的部分来决定。也就是说,如果我们调用了走、游泳、叫这三个方法,那么传入的类型只需要存在这几个方法即可(而不需要类型结构完全一致)。但由于 TypeScript 本身并不是在运行时进行类型检查(也做不到),同时官方文档中同样认为这两个概念是一致的(***One of TypeScript’s core principles is that type checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural typing”.***)。因此在这里,我们可以直接认为鸭子类型与结构化类型是同一概念。

除了基于类型结构进行兼容性判断的结构化类型系统以外,还有一种基于类型名进行兼容性判断的类型系统,标称类型系统。

# 标称类型系统

标称类型系统(Nominal Typing System)要求,两个可兼容的类型,其名称必须是完全一致的,比如以下代码:

type USD = number;
type CNY = number;

const CNYCount: CNY = 200;
const USDCount: USD = 200;

function addCNY(source: CNY, input: CNY) {
  return source + input;
}

addCNY(CNYCount, USDCount)
1
2
3
4
5
6
7
8
9
10
11

在结构化类型系统中,USD 与 CNY (分别代表美元单位与人民币单位)被认为是两个完全一致的类型,因此在 addCNY 函数中可以传入 USD 类型的变量。这就很离谱了,人民币与美元这两个单位实际的意义并不一致,怎么能进行相加?

在标称类型系统中,CNY 与 USD 被认为是两个完全不同的类型,因此能够避免这一情况发生。在《编程与类型系统》一书中提到,类型的重要意义之一是限制了数据的可用操作与实际意义,这一点在标称类型系统中的体现要更加明显。比如,上面我们可以通过类型的结构,来让结构化类型系统认为两个类型具有父子类型关系,而对于标称类型系统,父子类型关系只能通过显式的继承来实现,称为标称子类型(Nominal Subtyping)

class Cat { }
// 实现一只短毛猫!
class ShorthairCat extends Cat { }
1
2
3

C++、Java、Rust 等语言中都主要使用标称类型系统。那么,我们是否可以在 TypeScript 中模拟出标称类型系统?

# 在 TypeScript 中模拟标称类型系统

再看一遍这句话:类型的重要意义之一是限制了数据的可用操作与实际意义。这往往是通过类型附带的额外信息来实现的(类似于元数据),要在 TypeScript 中实现,其实我们也只需要为类型额外附加元数据即可,比如 CNY 与 USD,我们分别附加上它们的单位信息即可,但同时又需要保留原本的信息(即原本的 number 类型)。

我们可以通过交叉类型的方式来实现信息的附加:

export declare class TagProtector<T extends string> {
  protected __tag__: T;
}

export type Nominal<T, U extends string> = T & TagProtector<U>;
1
2
3
4
5

在这里我们使用 TagProtector 声明了一个具有 protected 属性的类,使用它来携带额外的信息,并和原本的类型合并到一起,就得到了 Nominal 工具类型。

有了 Nominal 这个工具类型,我们可以尝试来改进下上面的例子了:

export type CNY = Nominal<number, 'CNY'>;

export type USD = Nominal<number, 'USD'>;

const CNYCount = 100 as CNY;

const USDCount = 100 as USD;

function addCNY(source: CNY, input: CNY) {
  return (source + input) as CNY;
}

addCNY(CNYCount, CNYCount);

// 报错了!
addCNY(CNYCount, USDCount);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这一实现方式本质上只在类型层面做了数据的处理,在运行时无法进行进一步的限制。我们还可以从逻辑层面入手进一步确保安全性:

class CNY {
  private __tag!: void;
  constructor(public value: number) {}
}
class USD {
  private __tag!: void;
  constructor(public value: number) {}
}
1
2
3
4
5
6
7
8

相应的,现在使用方式也要进行变化:

const CNYCount = new CNY(100);
const USDCount = new USD(100);

function addCNY(source: CNY, input: CNY) {
  return (source.value + input.value);
}

addCNY(CNYCount, CNYCount);
// 报错了!
addCNY(CNYCount, USDCount);
1
2
3
4
5
6
7
8
9
10

通过这种方式,我们可以在运行时添加更多的检查逻辑,同时在类型层面也得到了保障。

这两种方式的本质都是通过非公开(即 private / protected )的额外属性实现了类型信息的附加,从而使得结构化类型系统将结构一致的两个类型也视为不兼容的。另外,在 type-fest 中也通过 Opaque Type (opens new window) 支持了类似的功能,其实现如下:

declare const tag: unique symbol;

declare type Tagged<Token> = {
    readonly [tag]: Token;
};

export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;
1
2
3
4
5
6
7

总结一下,在 TypeScript 中我们可以通过类型或者逻辑的方式来模拟标称类型,这两种方式其实并没有非常明显的优劣之分,基于类型实现更加轻量,你的代码逻辑不会受到影响,但难以进行额外的逻辑检查工作。而使用逻辑实现稍显繁琐,但你能够进行更进一步或更细致的约束。

# 总结与预告

在这一节中,我们了解了 TypeScript 的结构化类型系统是基于类型结构进行比较的,而标称类型系统是基于类型名来进行比较的。以及在 TypeScript 中,如何通过为类型附加信息的方式,从类型层面或者逻辑层面出发去模拟标称类型系统。如果你在实际上的业务代码中遇到过单位转换这种类型问题,不妨考虑使用这种方式,来进一步提升项目中类型的安全性。

我想,这一节或许能够解答你曾经有过的,“为什么这两个类型竟然是能被视为兼容问题”,“为什么这两个类型明明是父子关系却说不兼容”等问题。同时在后面的条件类型、类型层级等内容中,也还会有结构化类型系统出场的部分。

下一节,我们要来了解一个 TypeScript 中常常被忽略的部分,也就是 TypeScript 的类型层级。我们都知道,变量的类型之间需要存在兼容性才能进行赋值,而按照这个兼容性一层层地扩展出来,我们就得到了 TypeScript 类型系统中的类型层级,了解类型层级以后再学习条件类型与工具类型,简直不要太 easy。

# 扩展阅读

# 类型、类型系统与类型检查

对于类型、类型系统、类型检查,你可以认为它们是不同的概念。

  • 类型:限制了数据的可用操作、意义、允许的值的集合,总的来说就是访问限制赋值限制。在 TypeScript 中即是原始类型、对象类型、函数类型、字面量类型等基础类型,以及类型别名、联合类型等经过类型编程后得到的类型。
  • 类型系统:一组为变量、函数等结构分配、实施类型的规则,通过显式地指定或类型推导来分配类型。同时类型系统也定义了如何判断类型之间的兼容性:在 TypeScript 中即是结构化类型系统。
  • 类型检查:确保类型遵循类型系统下的类型兼容性,对于静态类型语言,在编译时进行,而对于动态语言,则在运行时进行。TypeScript 就是在编译时进行类型检查的。

一个需要注意的地方是,静态类型与动态类型指的是类型检查发生的时机,并不等于这门语言的类型能力。比如 JavaScript 实际上是动态类型语言,它的类型检查发生在运行时。

另外一个静态类型与动态类型的重要区别体现在变量赋值时,如在 TypeScript 中无法给一个声明为 number 的变量使用字符串赋值,因为这个变量在声明时的类型就已经确定了。而在 JavaScript 中则没有这样的限制,你可以随时切换一个变量的类型。

另外,在编程语言中还有强类型、弱类型的概念,它们体现在对变量类型检查的程度,如在 JavaScript 中可以实现 '1' - 1 这样神奇的运算(通过隐式转换),这其实就是弱类型语言的显著特点之一。

Last Updated: 10/9/2023, 5:43:25 PM