• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • TypeScript 全面进阶指南

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

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

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

class Cat {
  eat() {}
}

class Dog {
  eat() {}
}

function feedCat(cat: Cat) {}

feedCat(new Dog())

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

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

本节代码见:Structual 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 这样神奇的运算(通过隐式转换),这其实就是弱类型语言的显著特点之一。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
9.类型编程基石:TypeScript 中无处不在的泛型
Next
11.类型系统层级:从 Top Type 到 Bottom Type