了解 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;
}
上述的 Animal
和 Dog
两种类型的名称不一样,但是他们的类型结构是一样的,即都仅含有一个 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
}
上述代码中,Animal
和 Dog
虽然类型结构一样,但是因为 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 时,如果没定义任何属性,此时这个类型等价于 {}
类型,这种类型允许设置除了 null
和 undefined
之外的任何类型:
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
- https://en.wikipedia.org/wiki/Type_system
- https://en.wikipedia.org/wiki/Structural_type_system
- https://en.wikipedia.org/wiki/Nominal_type_system
文章/帖子
- https://github.com/Microsoft/TypeScript/issues/202
- https://medium.com/@thejameskyle/type-systems-structural-vs-nominal-typing-explained-56511dd969f4