likes
comments
collection
share

类型编程原理和编写类型安全代码

作者站长头像
站长
· 阅读数 17

作者:Qiuyi

证明即程序,结论公式即程序类型。 —— 柯里-霍华德对应

背景

我们每天的编码都会使用到类型系统,本篇文章希望能够简单地介绍原理到实践,让读者能更好的使用类型系统编写出类型安全并简洁的代码。

本篇文章预期读者是拥有 TypeScript 基础的同学。

CodeShare - 安全的 any 互操作

众所周知,any 是一个危险的类型,可以关闭所有类型检查。 但是实际的浏览器程序中不可能完全避免 any 类型进入类型系统,对我们的类型推理产生影响。比如

对于 any 的处理,最佳方法是先把他变成 TypeScript 的顶层类型 unknown,这样它就不能在类型系统中随意传播了,必须要求程序员主动进行类型转换才能在其他地方使用。

分享一个代码片段,这个代码片段尝试将 window 上的挂载的一个全局方法获取出来,假如存在,就转换成安全的类型后再放出去;假如不存在,就换成一段 fallback 逻辑并展示警告信息。

export type I18NMethod = (key: string, options: unknown, fallbackText: string) => string;

function isI18nFunction(input: unknown): input is I18NMethod {
  return typeof input === 'function';
}

function makeI18nMethod(): I18NMethod {
  let hasWarnShown = false;

  return function (key: string, options: unknown, fallbackText: string) {
    if (Reflect.has(window, '$i18n')) {
      // $i18n是一个挂载到 window 对象上的全局方法
      const globalI18n: unknown = Reflect.get(window, '$i18n');
      if (isI18nFunction(globalI18n)) {
        return globalI18n(key, options, fallbackText);
      }
    }
    showWarnOnce();
    return fallbackText;
  };

  function showWarnOnce() {
    if (hasWarnShown === false) {
      hasWarnShown = true; // 只展示一次警告
      console.warn('Cannot Fetch i18n Text: window.$18n is not a valid function');
    }
  }
}

export const $i18n = makeI18nMethod();

// usecase
$i18n("hello-text-key", {}, "你好");

13 行获取了一个 any 类型的对象,第一步是将其转换为 unknown 类型。

假如 14 行不调用 isI18nFunction 转换类型,而是直接返回 globalI18n,ts 将报错:Type 'unknown' is not assignable to type 'string',从而要求开发者必须编写类型转换。

💡 本文中所有 TypeScript 示例代码都可以复制粘贴放进 TypeScript Playground 运行。

非常推荐读者这样做,可以看到编译器真实的类型推断过程。

类型编程原理和编写类型安全代码

这里我采用了 typescript 的 is 语法来进行一个运行时类型检测,通过后进行类型转换。从而使得运行时类型更安全。

类型系统基础原理

CodeShare 中提到通过将 any 转换成了顶层类型 unknown,从而确保了类型安全

要理解这个操作需要回答四个问题:

  1. 为什么直接用 any 不安全?
  2. 顶层类型是什么?
  3. 为什么顶层类型是安全的?
  4. unknwon 为什么是顶层类型?

要回答这些问题,我们需要理解类型系统为什么把一些类型转换当作安全的(可以隐式转换),另一些类型转换当作不安全的(需要用 as 强制类型转换)。换句话说,需要了解类型系统的推导原理。

子类型

类型系统的推导原理是子类型系统,所以我们首先来看什么是子类型。

子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型,反之则称为父类型。

类型编程原理和编写类型安全代码

假设一个函数接受一个 Shape 的参数,如果此时能安全地传入 Rect,那么 Rect 就是 Shape 的子类型。

TypeScript 使用了结构子类型 (Structural Type System) 来实现子类型系统:如果 A 类型拥有 B 类型全部相同的结构,A 就是 B 的子类型。

以下示例演示 typescript 的基础子类型推导。注意本篇文章全部使用 class 表示类型,是因为这里是为了简化代码说明子类型原理,而非解释狭义的类型定义语法(type 或 interface)。

class Employee{
    public base=4000;
}

class Programmer extends Employee {
    public base=5000;
}

class Designer {
    public base=5000;
}

class Advertiser{
    public bonus=6000;
}

function getSalary(who: Employee): number{
    return who.base;
}

// OK,类型一致
getSalary(new Employee())

// Ok,Programmer 是 Employee 的子类型,编译器可以安全的做隐式类型转换 Programmer -> Employee
getSalary(new Programmer())

// Ok,Designer 虽然没有声明是 Employee 的子类型,但是由于结构子类型的定义,Designer 是安全的
getSalary(new Designer())

// Error Advertiser 不是 Employee 的子类型,这里不能做隐式类型转换
getSalary(new Advertiser())

// OK,我们可以强制转换。但是这样不安全。
getSalary(new Advertiser() as unknown as Employee)

any 类型

any 实际上是一个 TypeScript 的特例,是作为关闭“绕过类型检查”的标志,用来和 JavaScript 互操作。如果非要从类型系统的角度看,any 既是任何类型的子类型,又是任何类型的父类型。因为太特殊了,一般不把 any 作为顶层或底层类型看待。

let aAny: any = 1;
let aNumber: number = 1;

aAny = aNumber; // OK
aNumber = aAny; // OK

any 既是任何类型的子类型,又是任何类型的父类型。

any 类型会让 TS 关闭所有类型检查,非常不安全。

顶层类型

当一个类型是其他所有可能的类型的父类型,则称之为顶层类型。

回顾一下子类型的定义:如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。

换句话说,顶层类型就是在声明使用顶层类型的地方,可以安全地传入其他任意类型。从这个推理出发,我们可以发现顶层类型是:unknown

let aUnknown: unknown = 1;
let aNumber: number = 1;

aUnknown = aNumber; // OK,number 可以赋给 unknown,因为 number 是 unknown 的子类型
aNumber = aUnknown; // Error: unknown 不是 number 的子类型

unknown 顶层类型的特性演示

从定义我们知道,顶层类型不是任何类型的子类型,所以使用在任何声明非顶层类型使用的地方,都必须经过强制类型转换。

类型转换

在子类型示例中我们写了一段强制类型转换的代码:

// Advertiser -> unknown -> Employee
getSalary(new Advertiser() as unknown as Employee)

这里的 as unknown 其实是必须的,并不是写着玩。读者可以尝试在 TypeScript Playground 中尝试删除中间的 as unknown,编译器会直接报错:

Conversion of type 'Advertiser' to type 'Employee' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
  Property 'base' is missing in type 'Advertiser' but required in type 'Employee'.

这是因为 TypeScript 只允许父子类型之间进行类型转换。换句话说,只允许将类型向上转换为父类型,或者将类型向下转换为子类型。而 unknown 作为顶层类型,就可以在任何地方承担转换的“中间态”。

作为一个类型系统而言,TypeScript 这个设计是合理且安全的,不是 Bug。

类型编程原理和编写类型安全代码

  • 子类型到父类型转换:称为向上转换,是安全的,可以隐式转换;
  • 父类型到子类型转换:称为向下转换,是不安全的,需要主动声明才能转换;
  • 非父子类型间类型转换:非法行为。

总结

  • 子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。

  • 只有父子类型之间才能进行类型转换。

  • 为什么 any 类型不安全? 因为 any 既是任何类型的子类型,又是任何类型的父类型,可以绕过所有 TS 类型检查。

  • 什么是顶层类型? 当一个类型是其他所有类型的父类型,则称之为顶层类型。

  • 为什么顶层类型安全? 因为顶层类型不是任何类型的子类型,在接收其他类型地方,必须经过手动强制类型转换。强制类型转换需要开发者主动声明,让开发者告诉编译器:我已经做好了所有检测,可以进行转换。

  • 为什么 unknown 是顶层类型? 任何类型的值都可以赋给 unknown,但是 unknown 类型的值不能赋给其他类型(any 除外)。

编写类型安全代码

类型编程最大的应用就是用来对代码进行静态检查,减少潜在的 bug。

TypeScript 设置

对于 TS 来说,非常建议开启两个选项,新项目最好一开始就打开:

  • strictNullChecks 选项让 null 和 undefined 成为单元类型。
  • strictFunctionTypes 确保函数中返回值类型是协变的,而参数类型是逆变的,这样函数子类型更安全。(协变和逆变的概念见本文“类型可变性”章节)

基本类型偏执

基本类型 number string boolean不好的点在于:这些类型携带的可读性信息不足,并且对使用者暴露了太多细节。

比如一个防抖函数:

declare function debounce<Args extends unknown[], Output>(
    wait: number, fn: (...args: Args) => Output
): (...args: Args) => Output;

// useCase
const debouncedLog = debounce(500, (input:string) => console.log(input))

这里的问题是:

  • 500 是指什么?500 秒还是 500 毫秒?
  • wait 传入 -1 会发生什么?

对于有具体意义的概念不愿意建模,而是用基本类型表示,这种问题称为基本类型偏执。(出处:《重构,改善既有代码的设计》

我们新增一个简单的 Millseconds 类型来解决问题:

declare function debounce<Args extends unknown[], Output>(
    wait: Millseconds, fn: (...args: Args) => Output
): (...args: Args) => Output;

class Millseconds {
  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

// useCase:
const debouncedLog = debounce(new Millseconds(500), (input: string) => console.log(input))

这样我们的可读性就好了很多,无论是谁都能直接读出来我们在设置一个 500 毫秒等待时间的防抖函数。

优化:模拟名义子类型

然而这里还有一个问题,由于 TypeScript 是一个基于结构子类型的类型系统,只要结构类型相同就可以在这里顺利传入。

declare function debounce<Args extends unknown[], Output>(
    wait: Millseconds, fn: (...args: Args) => Output
): (...args: Args) => Output;

class Millseconds {
  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

class Seconds {
  constructor(readonly value: number){}
}

const debouncedLog = debounce(new Seconds(500), (input: string) => console.log(input))

在本文其实一直在用一个操作来模拟名义子类型,用一个 unique symbol 来强制类型结构独一无二,无法仿造。

declare const msSym: unique symbol;
class Millseconds {
  private [msSym] = null;

  constructor(readonly value: number){
      if(this.value < 0){
          throw new Error('Millseconds Value Cannot Smaller Than 0');
      }
  }
}

优化:字面量检测

然而这里还有一个问题:虽然报错信息好了很多,但是传入小于 0 的数还是只能在运行阶段报错。

就算写new Millseconds(-1)这种明显的错误,类型系统依然躺平装死。为了解决这个问题,我们可以用 TS 新增的 string literal 特性(需要 TS 大于 4.5)来搞一点点字面量体操:

// <N extends number> 要求 N 是 number 的子类型
// 第一个判断条件:`number extends N ?` 意思是如果 number 是 N 的子类型,就进入分支 1,否则进入分支 2
// 第一个条件分支:如果 number 是 N 的子类型,则类型是 N,又已知 N 是 number 的子类型,那么 N = number
// 第二个条件分支:如果`${N}` 的字符串字面量是 `-${string}` 的子类型,返回空类型,否则返回N
type AssertPositive<N extends number> =
  number extends N ?
     N :
    `${N}` extends `-${string}` ? never : N;

class Millseconds<N extends number> {
  constructor(public readonly value: AssertPositive<N>){
      if(this.value < 0){
          throw new Error('Value Cannot Smaller Than 0');
      }
  }
}

new Millseconds(0); // OK
new Millseconds(1); // OK
new Millseconds(-1); // Error

实施类型约束

基本类型偏执模式的思路可以用到其他地方。假设我们需要有一个定时器,指定一个未来的绝对时间,在那个时间执行操作:

declare function setTimer(absoluteTime: Date, callback: () => void): void;

setTimer(new Date("2024-01-01T00:00:00"), console.log.bind(null, 'Happy New Year!');

这里可读性还是不错的,很容易读出来这里是要在 24 年元旦节祝你新年快乐。但是这里使用 Date 无法表明要一个未来的时间。

和基本类型偏执一样,我们可以套一个用于检测约束的类型来优化:

declare function setTimer(absoluteTime: FutureDate, callback: () => void): void;

class FutureDate {
  constructor(public readonly date: string){
      const targetDate = new Date(date);
      if(!isNaN(targetDate.getTime()) || targetDate.valueOf() < new Date().valueOf()){
          throw new Error('Error: Must provide a future date')
      }
  }
}

setTimer(new FutureDate("2024-01-01T00:00:00"), console.log.bind(null, 'Happy New Year!'))

这种类型检测的模式可以套用在很多类型信息不具体的地方。

用运行时信息辅助类型系统

安全的 any 互操作例子中,已经演示了怎么用运行时的数据来帮助类型系统更加健壮。在第 4 行调用 typeof 来获取变量运行时类型名称,根据运行时类型来进行强制类型转换(TypeScript 的 is 返回值是一种类型向下转换)。

运用类似的思路,可以依据运行时信息编写让类型转换更安全的代码,从而健壮我们的类型推导。

这里举个例子,swift 语言中有一个经典的 Optional 类型设计。

let number: Int? = Optional.some(42);

if number == nil {
  print('number is nil')
} else {
  print('The value is {number}')
}

TypeScript 3.7 已经用和类型 T | undefined 实现了类似的语法Optional Chain。假设 TS 中没有实现这个语法,我们需要手动写一个 Optional 类型,如下代码所示。

class Optional<T> {
    private assigned = false;

    constructor(public value: T | undefined) {
        if (value !== undefined) {
            this.assigned = true;
        }
    }

    hasValue() { return this.assigned }

    setValue(value?: T){
       if (value !== undefined) {
            this.assigned = true;
            this.value = value;
        }
    }

    getValue(): T {
        if (this.assigned) {
            return this.value as T
        }
        throw new Error('OptionalError: Value has not be assigned')
    }
}

const maybeNumber = new Optional<number>(1);

// unboxing check
if(maybeNumber.hasValue()){
  // `T | undefined` -> `T`
  const mustbeNumber: number = maybeNumber.getValue();
}

其中第 20 行通过判断一个附加信息(this.assigned)后进行类型转换,安全地将 undefined 排除出和类型 T | undefined

深入类型系统原理

如果你并不满足于了解最基本的类型系统原理,那就可以看一下以下内容。

类型可变性

现在我们知道了基础的子类型原理。假设我们现在有一个 Programmer 是 Employee 的子类型(class Programmer extends Employee),考虑这几个问题:

  • 'A' | 'B''A' | 'B' | 'C' 的子类型关系如何?
  • Programmer[]Employee[] 的子类型关系如何?
  • 对于范型结构 List<Programmer>List<Employee> 的子类型关系如何?
  • () => Programmer() => Employee 的子类型关系如何?
  • (input:Programmer) => void(input: Employee) => void 的子类型关系如何?

在做这些证明之前,还是需要先明确子类型的定义: 子类型(subtype) :如果在期望类型 T 的实例的任何地方,都可以安全地使用类型 S 的实例,那么称类型 S 是类型 T 的子类型。

  • 对于和类型而言,父类型比子类型复杂度更高。换句话说,'A' | 'B''A' | 'B' | 'C' 的子类型。 证明: 假设一个函数要求参数是 'A' | 'B' | 'C',那么我们传入 'A' | 'B' 始终是合法的,反之则不行。所以 'A' | 'B''A' | 'B' | 'C' 的子类型。

  • 数组子类型关系和原类型子类型关系一致。

declare const employeeSym: unique symbol;

class Employee {
  [employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
  [programmerSym]: void
}

const employees: Employee[] = [new Programmer()]; // OK
const programmers: Programmer[] = [new Employee()]; // Error
  • 范型子类型关系和原类型子类型关系一致。
declare const employeeSym: unique symbol;

class Employee {
  [employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
  [programmerSym]: void
}

class List<T> {
  constructor(public readonly list: T[]){};
}

let eList:List<Employee> = new List([new Employee()])
let pList:List<Programmer> = new List([new Programmer()])

eList = pList; // OK
pList = eList;  // Error
  • 返回值子类型关系和原类型子类型关系一致。
declare const employeeSym: unique symbol;

class Employee {
  [employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
  [programmerSym]: void
}

function getEmployee(getter: () => Employee) {
  return getter()
}
getEmployee(() => new Employee()) // OK
getEmployee(() => new Programmer()) // OK

function getProgrammer(getter: () => Programmer) {
  return getter()
}
getProgrammer(() => new Programmer()) // OK
getProgrammer(() => new Employee()) // Error
  • 参数子类型关系和原类型子类型关系相反。
declare const employeeSym: unique symbol;

class Employee {
  [employeeSym]: void
}

declare const programmerSym: unique symbol;

class Programmer extends Employee {
  [programmerSym]: void
}

function useEmployee(setter: (e: Employee) => void) {
  return setter(new Employee())
}
function useProgrammer(setter: (e: Programmer) => void) {
  return setter(new Programmer())
}

const employeeUser = (e: Employee) => e;
const programmerUser = (e: Programmer) => e;

useEmployee(employeeUser) // OK
useEmployee(programmerUser) // Error
useProgrammer(employeeUser) // OK
useProgrammer(programmerUser) // OK

协变性:如果一个类型保留其底层类型的子类型关系,就称该类型具有协变性。

逆变性:如果一个类型颠倒了其底层类型的子类型关系,则称该类型具有逆变性。

从数学角度理解类型

类型:类型是对数据做的一种分类,定义了能够对数据执行的操作、数据的意义。编译器和运行时会检查类型,以确保数据的完整性,实施访问限制,以及按照开发人员的意图来解释数据。

从数学上来看,类型就是一个集合

  • number 类型,代表一个 64 位浮点数可以表示的所有数字的一个集合。
  • string 类型,代表一个无限的集合,所有字符串数据都在此集合中。

函数代表从一个集合到另外一个集合的映射。比如此函数类型定义:

type typeA = 'a' | 'b' | 'c' | 'd'
type typeB = 'm' | 'n' | 'p' | 'q'

type a2b = (a: typeA) => typeB;

类型编程原理和编写类型安全代码

a2b 函数可以表示从 A 集合到 B 集合到一个映射。

有多个函数参数的情况下,一个函数代表参数的积类型到返回值类型的一个映射。积类型的概念在本文后面介绍。

说完了类型,再来看看类型系统的定义。类型系统是一组规则,从职责上来看,一个具有类型系统的编程语言代表:

  • 可以用类型表示语言中的所有元素所在的集合,比如变量、函数、类、模块等;
  • 可以对类型进行逻辑运算推导,从而静态代码检查等功能。

名义子类型和结构子类型

子类型的概念比较抽象,没有指定具体实现方式,不同编程语言对子类型的实现不尽相同,但是一般可以分为两种类型:名义子类型结构子类型

名义子类型 Nominal Type System

名义子类型意味着当且仅当显式说明的情况下,两个类型才具有父子类型关系。采用这种实现的语言有 C++ Java C# 等。

// Java Compiler: https://www.jdoodle.com/online-java-compiler/
class Employee{
 public int base=4000;
}

class Programmer extends Employee{
 public int base=5000;
}

class Advertiser {
 public int base=6000;
}

class Business {
   public static int getSalary(Employee who){
       return who.base;
   }
}

public class Main {
  public static void main(String[] args){
      Business.getSalary(new Employee()); // output: 4000
      Business.getSalary(new Programmer()); // output: 5000

      // Incompatible Types Error: Advertiser cannot be converted to Employee
      Business.getSalary(new Advertiser());
  }
}

这是一段 Java 代码来演示名义子类型的特性。Employee Programmer Advertiser 都包含一个 base 字段,Business.getSalary 方法指定了接受一个 Employee 类型参数,并返回他的 base 字段。

因为名义子类型的要求,即使 Advertiser 的结构和 Employee 一模一样,看起来 getSalary 方法也可以正常运行,也不允许输入。

结构子类型 Structural Type System

结构子类型意味着,A 类型只要具有 B 类型的全部相同结构,就可以认为 A 是 B 的子类型,而不用显式说明子类型关系。典型采用结构子类型的语言有 TypeScript 和 Scala。

// TS Compiler: https://www.typescriptlang.org/play?ts=4.8.4
class Employee{
    public base=4000;
}

class Programmer extends Employee{
    public base=5000;
}

class Advertiser {
    public base=6000;
    public bonus=1000;
}

function getSalary(who: Employee): number{
    return who.base;
}

getSalary(new Employee()) // 4000
getSalary(new Programmer()) // 5000
getSalary(new Advertiser()) // 6000

这是一段用 TypeScript 模仿上述 Java 示例写的代码。Employee Programmer Advertiser 都包含一个 base 字段,getSalary 方法指定了接受一个 Employee 类型参数。

和名义类型系统的差别是 getSalary(new Advertiser()) 可以正常运行,因为 Advertiser 包含全部 Employee 的相同结构,而不用显式声明 Advertiser 和 Employee 的关系。

名义 vs 结构

实际上,当在名义子类型语言中,声明为父子类型的类型也要求有相同的结构。所以可以认为名义子类型比结构子类型的推导更严格,是结构子类型推导的一个子集。

结构子类型可以表达为:

A is a subtype of B
  when A is structurally identical to B

名义子类型就表达为:

A is a subtype of B
  when A is structurally identical to B
      and A is declared to be a subtype of B

一般来说,使用结构子类型可以使类型系统更灵活;反之,名义子类型的使得类型检查更严格。具体差别还是要看不同语言的实现细节。

其他特殊类型和用法

除了顶层类型和 any 类型之外,还有其他的特殊类型。

底层类型

当一个类型是其他所有可能的类型的子类型,则称之为底层类型。换句话说,底层类型就是在声明使用任何类型的地方,都可以安全地传入的类型

在 TypeScript 这种结构子类型系统的语言中,一个类型如果要是所有类型的子类型,那么就必须包含所有类型的结构。不可能创建出来一个变量满足这种要求,所以底层类型只有一个: never

declare let aNever: never; // 由于不可能创建一个 Never 变量,所以这里使用了 declare
let aNumber: number = 1;

aNumber = aNever; // OK, never 是底层类型,所以是 number 的子类型
aNever = aNumber; // Error: number 不是 never 的子类型

单元类型

单元类型:只有一个值的类型。对于这种类型的变量,检查其值是没有意义的,它只能是那一个值。

对于 TypeScript (严格模式)来说,单元类型有三个:void null undefined。

当函数的结果没有意义时,我们会使用单元类型,一般来说我们都会用 void。为什么不用 null 和 undefined?因为 TypeScript 语言层面上限制 void 值只能从不返回的函数中产生,可以用来确保函数没有任何返回语句。

const log(message:string): void{
  console.log(message);
}

自己实现一个单元类型比较简单,就是写一个单例模式:

declare const unitSymbol: unique symbol;

class Unit {
  [unitSymbol]: unknown; // 模拟名义子类型

  static readonly unit: Unit = new Unit();  // 唯一单例

  private constructor(){} // 私有化构造器保证没有其他 instance
}

function getUnit(): Unit {
    return Unit.unit; // 只能返回唯一的单例 Unit.unit
}

getUnit()

空类型

空类型:没有值的类型。

对于 TypeScript 来说,空类型只有一个:never。

一般我们只在函数不返回的情况下使用空类型作为返回值,比如抛出错误:

function raise(message:string): never{
  throw new Error(message);
}

另外还有无限循环函数也可以返回空类型(一般在图形学程序中比较多):

function mainLoop(): never {
    while(true) {
        /** ... */
    }
}

当你写单例模式不要单例,就产生了一个空类型。但自制空类型一般没有什么意义,一个编程语言中也往往只要一个空类型,为了好读还是用 never 比较合适。为了演示,自制空类型代码如下:

declare const unitSymbol: unique symbol;

class Void {
  [unitSymbol]: unknown; // 模拟名义子类型

  private constructor(){} // 私有化构造器保证没有 instance
}

function raise(message:string): Void { // 不返回的函数可以返回自制的空类型
  throw new Error(message);
}

类型组合复杂度

大部分语言类型组合按复杂度一般有两种:

  • 和类型 Sum Type 代数上可以表达为 AB = A + B。即 AB 的复杂度是 A 的复杂度和 B 的复杂度之和。在 TypeScript 中,和类型就是联合类型:
type A = 'A1' | 'A2' | 'A3';
type B = 'B1' | 'B2';

type AB = A | B; // AB 可能值有 5 个 = type A 3 个 + type B 2 个
  • 积类型 Product Type 代数上可以表达为 AB = A * B。即 AB 的复杂度是 A 的复杂度和 B 的复杂度之乘积。在 TypeScript,积类型包括元祖、对象等等。
type A = 'A1' | 'A2' | 'A3';
type B = 'B1' | 'B2';

type ABTuple = [A, B]; // 可能值有 6 个 = type A 3 个 * type B 2 个
type ABObject = { a: A, b: B }; // 可能值有 6 个 = type A 3 个 * type B 2 个

还有一种类型组合比较罕见,一般只在结构子类型系统中存在:

  • 交叉类型 Intersection Type 交叉类型并没有增加类型复杂度,而是根据两个输入类型 A B 的结构创建一个类型 C,其中 C 既是 A 的子类型,也是 B 的子类型。TypeScript 中交叉类型实现是 '&' 类型。
type A = { a: boolean }
type B = { b: number }

type C = A & B; // C 既是 A 的子类型,也是 B 的子类型

参考资料

Nominal And Structural Typing

product / sum / union / intersection types

编程与类型系统

类型编程原理和编写类型安全代码

扫码关注公众号 👆 追更不迷路