10-结构化类型系统:类型兼容性判断的幕后
课程
1
开篇:用正确的方式学习 TypeScript
已学完
学习时长: 7分24秒
2
工欲善其事:打造最舒适的 TypeScript 开发环境
学习时长: 19分14秒
3
进入类型的世界:理解原始类型与对象类型
学习时长: 37分40秒
4
掌握字面量类型与枚举,让你的类型再精确一些
学习时长: 24分53秒
5
函数与 Class 中的类型:详解函数重载与面向对象
学习时长: 50分40秒
6
探秘内置类型:any、unknown、never 与类型断言
学习时长: 34分58秒
7
类型编程好帮手:TypeScript 类型工具(上)
学习时长: 33分34秒
8
类型编程好帮手:TypeScript 类型工具(下)
学习时长: 40分52秒
9
类型编程基石:TypeScript 中无处不在的泛型
学习时长: 43分46秒
10
结构化类型系统:类型兼容性判断的幕后
学习时长: 20分48秒
11
类型系统层级:从 Top Type 到 Bottom Type
学习时长: 45分16秒
12
类型里的逻辑运算:条件类型与 infer
学习时长: 52分44秒
13
内置工具类型基础:别再妖魔化工具类型了!
学习时长: 44分47秒
14
反方向类型推导:用好上下文相关类型
学习时长: 18分37秒
15
函数类型:协变与逆变的比较
学习时长: 21分38秒
16
了解类型编程与类型体操的意义,找到平衡点
学习时长: 4分27秒
17
内置工具类型进阶:类型编程进阶
学习时长: 83分3秒
18
基础类型新成员:模板字符串类型入门
学习时长: 32分58秒
19
类型编程新范式:模板字符串工具类型进阶
学习时长: 69分27秒
20
工程层面的类型能力:类型声明、类型指令与命名空间
学习时长: 53分23秒
21
在 React 中愉快地使用 TypeScript:内置类型与泛型坑位
学习时长: 78分9秒
22
让 ESLint 来约束你的 TypeScript 代码:配置与规则集介绍
学习时长: 57分13秒
23
全链路 TypeScript 工具库,找到适合你的工具
学习时长: 25分13秒
24
说说 TypeScript 和 ECMAScript 之间那些事儿
学习时长: 29分14秒
25
装饰器与反射元数据:了解装饰器基本原理与应用
学习时长: 81分53秒
26
控制反转与依赖注入:基于装饰器的依赖注入实现
学习时长: 69分6秒
27
TSConfig 全解(上):构建相关配置
学习时长: 55分33秒
28
TSConfig 全解(下):检查相关、工程相关配置
学习时长: 76分19秒
29
基于 Prisma + NestJs 的 Node API :前置知识储备
学习时长: 44分50秒
30
基于 Prisma + NestJs 的 Node API :项目开发与基于 Heroku 部署
学习时长: 46分40秒
31
玩转 TypeScript AST:AST Checker 与 CodeMod
学习时长: 85分18秒
32
感谢相伴:是结束,也是开始
学习时长: 3分21秒
33
漫谈篇:面试中的 TypeScript
学习时长: 4分25秒
juejin_logo copyCreated with Sketch.

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

class Cat {
  eat() { }
}

class Dog {
  eat() { }
}

function feedCat(cat: Cat) { }

feedCat(new Dog())

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

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

本节代码见:Structural Type System

结构化类型系统

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

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

class Dog {
  eat() { }
}

function feedCat(cat: Cat) { }

// 报错!
feedCat(new Dog())

这是因为,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())

这个时候为什么却没有类型报错了?这是因为,结构化类型系统认为 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())

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

这就是结构化类型系统的核心理念,即基于类型结构进行判断类型兼容性。结构化类型系统在 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)

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

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

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

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>;

在这里我们使用 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);

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

class CNY {
  private __tag!: void;
  constructor(public value: number) {}
}
class USD {
  private __tag!: void;
  constructor(public value: number) {}
}

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

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);

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

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

declare const tag: unique symbol;

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

export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;

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

总结与预告

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

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

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

扩展阅读

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

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

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

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

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

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

留言
Ctrl + Enter
全部评论(36)
qijizh的头像
删除
declare class TagProtector<T extends string> {
protected __tag__: T;
}
为什么用declare声明不会报属性未初始化的错误
点赞
回复
丝绒拿铁有点甜的头像
删除
web前端
我看了,又似乎没看
点赞
回复
朱俊宇的头像
删除
前端开发
问一个题外问题,为啥typescript不是用js写的,自己写自己?
image
点赞
1
删除
(作者)
这个称为自举,有兴趣的话可以google下,或者参考:en.wikipedia.org(compilers)
点赞
回复
Zicxxciz的头像
删除
前端工程师 @ ****
打卡
点赞
回复
用户2362677620139的头像
删除
打卡
点赞
回复
多吃牛肉减肥的头像
删除
鸭子类型原来是这样来的
点赞
回复
首席CV工程师的头像
删除
前端开发 @ 国家摸鱼办
mark: 这一节有很多新概念,多看几遍。
2
回复
抽风的汉子的头像
删除
class CNY {
private __tag!: void;
constructor(public value: number) {}
}
class USD {
private __tag!: void;
constructor(public value: number) {}
}
为什么他们被认为是不同的类型, 他们明明有相同名称的私有属性和公开属性
收起
2
1
删除
(作者)
私有的意思是只能被类内部的成员访问
1
回复
叁十四城的头像
删除
export declare class TagProtector<T extends string> {
__tag__: T;
}

不加protected 好像也会报错, 这里protected作用体现在哪
另外,export type Nominal<T, U extends string> = T & [U];
这样也可以实现同样的效果,二者的优缺点又是什么呢
收起
1
回复
前端摸鱼儿的头像
删除
Structural Type System not Structual
点赞
1
删除
(作者)
FIXED
点赞
回复
涛涛_江的头像
删除
打卡
点赞
回复
前端踩坑人员的头像
删除
看不太懂
点赞
回复
RedChr的头像
删除
前端开发 @ 西厂十八摸
还是不太懂为什么在Dog类里添加一个方法后就不报类型错误了,Cat和Dog之间不存在继承关系,既然Cat可以,为什么Dog就不行
点赞
1
删除
ts 做静态编译的时候,会认为Dog继承自Cat(Dog存在跟cat相同的方法,也就是类型结构相同)
点赞
回复
前端摸鱼儿的头像
删除
declare这个是干嘛的啊
点赞
回复
用户4237968195784的头像
删除
哈喽,我问一下这个是面向无前端基础的人,还是有前端基础的人学习
点赞
2
删除
(作者)
需要有前端基础,以及TS基础
点赞
回复
删除
感觉有js基础就行,没啥ts基础,就多看个两三遍,总能看懂。
点赞
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
太难了
点赞
回复
大王带我去巡山的头像
删除
看不懂啊,太难了
点赞
回复
卷王归来的头像
删除
web前端开发工程师
打卡
点赞
回复
CoderCrush的头像
删除
```js
class Cat () { }
// 实现一只短毛猫!

class ShorthairCat extends Cat () { }
```
这一块没有`()`吧
收起
点赞
1
删除
(作者)
已修正
点赞
回复
furfurJiang的头像
删除
前端 @ 程序江
两种方式模拟标称类型系统
1. 类型层面:使用type工具类型,用泛型坑位去传入唯一标识。
2. 逻辑层面:使用私有变量去标识区分SNY和USD 或者
原文:
> 这两种方式的本质都是通过非公开(即private/protected)的额外属性实现了类型信息的附加,从而使得结构化类型系统将结构一致的两个类型也视为不兼容的。

我觉得使用第一种方式非公开不是必需的,泛型坑位传入不同内容是必需的。不知道这样想对不对?[疑问]
收起
1
1
删除
(作者)
是的,附加上不同的类型信息才是最关键的,私有主要是为了避免被错误消费
1
回复
打拖鞋的春风的头像
删除
前端入坟师
有点难
2
1
删除
加油[嘿哈][嘿哈][嘿哈]
点赞
回复
rubin本尊的头像
删除
虽然对象类型和原始数据类型的交集,按集合逻辑应该是空集,但在ts中最终表现为 装箱 后与对象类型的属性并集。这实际上完全是为了另一个目的:nominal,即使得“类型别名”(type)具有唯一性(或者叫tag),官方FAQ有提及
2
4
删除
谢谢🙏
点赞
回复
删除
突然想问,哪里可以查到[看]
点赞
回复
删除
没有找到”最终ts的表现“这个阐述,只找到了github.com
点赞
回复
删除
回复
这个我之前忘了我在哪找到的,好像是知乎
没有找到”最终ts的表现“这个阐述,只找到了github.com
点赞
回复
zhedream的头像
删除
全栈工程师 @ @zhedream
mark
点赞
回复
The action has been successful