了解 Typescript 中的 Structural Type System(结构类型系统)

读者们平时在阅读或编写 typescript 代码时,可能会碰到这种的写法:

type UserId = string;
function queryUser(id: UserId, pwd: string) {}

const userId = "abc";
const pwd = "***";
queryUser(userId, pwd);

上述中为 string 设置了个类型别名 UserId,这可以让类型表达更明确,但是在使用时,由于本质上它仍是字符串,突然有一天,名为"小心"的有志青年拖着疲惫的身躯加班,不小心参数传递写反了:

queryUser(pwd, userId); // compile ok

由于都是字符串,ts 编译器并没有报错,昏昏沉沉的脑袋自我感觉一切良好便提交测试了,回到家躺到床上,一个加急消息被通知有个测试 bug 请及时处理。

对于这种问题场景,我们会在网上找到这种解法:

type UserId = string & { __annotation: "User id" };

queryUser(userId, pwd); // compile error

通过给 UserId 额外添加一个属性 __annotation,此时这个类型是个 string 又不像个 string,但确实避免了类型传错的情况。

为何通过添加一个额外属性,就可以让两个类型不相等了呢? 让我们来了解一下类型系统(Type system)设计中的类型相等性判断(Type Equivalence)吧。

怎么的两种类型才算是相等的,又怎么可以让两个类型不相等,带着这个疑问,我们会碰到这么两个概念:名义类型系统(Nominative/Nominal type system)和结构化类型系统(Structural type system)(中文翻译后的名词听起来真的非常怪)。

Type system

类型系统作为编程语言的一部分,为语言提供了类型能力,其中在判断两个类型是否相等时,主要有下面两种形式:

Structural type system

structural 中文释义是“结构上的”,顾名思义,也就指的是根据类型结构判断相等性,typescript 使用的就是种类型系统

interface Animal {
  type: string;
}
interface Dog {
  type: string;
}

上述的 AnimalDog 两种类型的名称不一样,但是他们的类型结构是一样的,即都仅含有一个 string 类型的 type 属性,因此在 ts 中这两个类型是等价的,你可以在任何接收 Animal 的地方传入 Dog,反之亦然。下述代码不会产生编译报错:

interface Animal {
  type: string;
}
interface Dog {
  type: string;
}

function setAnimal(animal: Animal) {}
function setDog(dog: Dog) {}

const animal: Animal = { type: "animal" };
const dog: Dog = { type: "dog" };

setAnimal(dog); // ok
setDog(animal); // ok

Nominal type system

nominal 中文释义是“名义上的”,顾名思义,也就指根据名字来判断相等性,比如 rust 语言便使用到了这种类型系统。现在我们将上述的 ts 代码改为 rust 代码来了解下 nominal 类型:

struct Animal {
    r#type: &'static str, // 这里要添加 “r#” 前缀是因为 "type" 是个关键字,不能直接作为属性名
}
struct Dog {
    r#type: &'static str,
}

fn set_animal(animal: Animal) {}
fn set_dog(dog: Dog) {}

fn demo() {
    let animal = Animal { r#type: "animal" };
    let dog = Dog { r#type: "dog" };

    set_animal(dog); // error
    set_dog(animal); // error

    set_animal(animal); // ok
    set_dog(dog); // ok
}

上述代码中,AnimalDog 虽然类型结构一样,但是因为 rust 使用到了 nominal 类型系统,由于是两种类型的名称是分别定义的,因此不属于同一类型,传错类型会导致编译报错,必须要传入同名称的类型。

将 structural type 变为 nominal type

了解了 structural 和 nominal 类型系统后,那么如何在 ts 将 structural type 变成 nominal type 呢?似乎开头都给出答案了,那就是改变类型结构,让两个类型的结构不一致便可:

interface Animal {
  type: string;
  __brand: "an animal";
}
interface Dog {
  type: string;
  __brand: "a dog";
}

更改类型结构后,也导致我们要修改所有用到该类型的地方,去给他们新增这个属性,我们可以使用一个函数封装一下:

function createAnimal() {
  return { type: "animal" } as Animal;
}

function createDog() {
  return { type: "dog" } as Dog;
}

const animal = createAnimal();
const dog = createDog();

由于这两个类型变成了 nominal 类型,最初的代码会由于传入了不对应的类型而编译报错:

setAnimal(dog); // error
setDog(animal); // error

setAnimal(animal); // ok
setDog(dog); // ok

上述提到的仅是实现 nominal type 的一种写法,此文 共列举了 4 种不同的写法,有兴趣的读者可前往阅览,当然在实际项目中,理应仅采用其中一种写法便可。

一些补充

空类型

你知道吗,在 ts 中定义 interface 或是 class 时,如果没定义任何属性,此时这个类型等价于 {} 类型,这种类型允许设置除了 nullundefined 之外的任何类型:

interface A {}
// Class A {}

let a: A = undefined; // error
a = null; // error
a = 1; // ok
a = {}; // ok
a = "str"; // ok

因此请尽量避免定义这种空属性的内容,或是添加一个占位属性将其变为 nominal 类型,以而避免留下类型隐患。

FAQ 文档

在理解了上述概念后,笔者再去前往查看 typescript 仓库 wiki 里的 FAQ 时,会发现很多以前看不懂在说什么的解答现在都有所理解了,其中的多数内容都和 typescript 的 structural type system 有关系。

结尾

nominal type system 相比于 structural type system 会使用时缺乏一些灵活性,而 structural type system 更灵活但由于不够“准确”,容易出现类型传递错误但无法在编译阶段察觉而留下隐患的问题,此文在 ts 中,将 structural 转为 nominal type 在写法上会略微繁琐,因此在平时使用时还需根据场景灵活行动,当然也期待 ts 在未来版本中提供一种更加便捷的写法。


一些资料

wiki references

文章/帖子