likes
comments
collection
share

TypeScript 类型编程

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

TypeScript 的类型系统非常强大,因为它允许用其他类型来表示类型。我们有很多类型操作符可以使用,也可以用我们已有的值来表示类型。通过组合各种类型操作符,我们可以用一种简洁、可维护的方式来表达复杂的操作和值。

类型编程就是使用这些类型操作符,通过类型来创建类型。对于一个 JavaScripter 来说,TypeScript 中的类型操作既熟悉又陌生。例如,条件类型 - T extends U ? X : Y 这不就是三元运算符,这个简单,我已经掌握了。

TypeScript 类型编程

可是当我们遇到 Exclude (type Exclude<T, U> = T extends U ? never : T)并看到它的实现时

TypeScript 类型编程

这都是啥啊!!!按照 JavaScript 的思路,const a = condition ? 1 : 2, a 不是 1 就是 2, 那么 Exclude 的返回值不就应该是 never 或者 T 啊?那type T0 = Exclude <"a" | "b" | "c", "a">T0 为什么不是 "a" | "b" | "c" 或者 never,而是 "b" | "c" 呢?

啊。。。这。。。如果用 JavaScript 的编程思维去看类型编程,类型编程总是有那么一些奇奇怪怪的行为无法解释。可我们在类型编程的时候,却总是不自觉地去和 JavaScript 比较,刚学习 TypeScript 的时候,我经常使用 . 语法去访问 interface 的一个属性 Person.name, 结果 TypeScript 会报错,原来要使用另一种语法 -- [] 来访问属性啊, Person['name'] 就可以正常访问 name 属性了。

TypeScript 类型编程

类型编程和 JavaScript 编程也并没有天差地别啊。本文以 JavaScript 编程的视角去学习类型编程。本文会涉及到 TypeScript 中的泛型 - Generics索引访问类型 Indexed Access Types条件类型 - Conditional Types映射类型 - Mapped TypesTemplate Literal Types等比较高阶的知识,所以不适合作为一篇 TypeScript 入门教程来阅读。它是一篇 TypeScript 的进阶文章,它可以帮助你读懂各种类库里的类型声明,也可以帮助你应对日常中的类型创建。


编程基础

操作数

学习编程,最开始我们要知道我们的操作数是啥,我们要对什么进行编程?既然是类型编程嘛,类型肯定就是我们的操作数,例如,number 在 TypeScript 中表示一个数字类型,在类型编程看来,number 就是我们的操作数。

将一个操作数赋值给一个变量

type A = number;

TypeScript 中的类型(booleannumberstringnull、数组、元组、枚举、字面量类型...)都可以作为我们的操作数。这里重点要说明一下字面量类型

TypeScript 支持将字面量作为类型使用,我们称之为字面量类型。每一个字面量类型都只有一个可能的值,即字面量本身。

例如,true 既可以是 JavaScript 中的值,又可以是 TypeScript 中的字面量类型; type A = true 这个语句是正确的,因为这里我们是将 true 看成了字面量类型。 字面量类型一共有:

  • boolean 字面量类型;
  • number 字面量类型;
  • string 字面量类型;
  • 枚举成员字面量类型。
// boolean 字面量类型
type A = true;
// number 字面量类型
type B = 1;
// string 字面量类型
type C = "2";

enum E {
  A,
}

// 枚举成员字面量类型
type D = E.A;

TypeScript 中的字面量类型和 JavaScript 中的值太容易被混淆了,我们在类型编程的时候,要注意区分值与类型,不要值和类型傻傻分不清。

const a = 1;

// error: 'a' refers to a value, but is being used as a type here.
type A = a;

type B = 1;

// 将类型 B 赋值给 C
type C = B;

我们将一个变量 a 赋值给类型 A 会出现 “error: 'a' refers to a value, but is being used as a type here.” 的错误,这是因为我们将一个值当做类型使用了。

TypeScript 类型编程

我们可以使用typeof将值转换成类型:

const a = 1;

type A = typeof a;

简单来说,TypeScript 中的所有类型都可以是我们的操作数。

操作符

就像 JavaScript 中,我们有加减乘除等这样的操作符可以对操作数进行运算,在类型编程中,我们也有可以对类型运算的操作符

联合类型 Union Types (|)

联合类型,看上去就和它和符号一样简单, type T = number | string 其含义就是 T 既可能是 number,有可能是 string。但是当 | 遇到 interface 后,行为就没有这么简单了。

  1. 若联合类型 T 中的每个成员类型都包含一个同名的属性签名 M,那么联合类型 T 也包含属性签名 M。
interface A {
  a: number;
  b: string;
}

interface B {
  a: number;
  c: string;
}

// { a: number }
type C = A | B;
  1. 对于联合类型的属性签名,其类型为所有成员类型中该属性类型的联合类型。
interface A {
  a: number;
}

interface B { 
  a: string;
}

// { a: number | string }
type C = A | B;
  1. 如果联合类型的属性签名在某个成员类型中是可选属性签名,那么该属性签名在联合类型中也是可选属性签名。
interface A {
  a?: number;
}

interface B {
  a: number;
}

// { a?: number }
type C = A | B;
  1. 如果联合类型中每个成员类型都包含相同参数列表的调用签名,那么联合类型也拥有了该调用签名,其返回值类型为每个成员类型中调用签名返回值类型的联合类型;否则,该联合类型没有调用签名(这条规则同样适用于构造签名)。
interface A {
  (x: string): number;
}

interface B {
  (x: string): string;
}

// { (x: string): number | string }
type C = A | B;

交叉类型 Intersection Types (&)

交叉类型,往往操作的是 interface,因为原始数据类型之间的交叉类型为 never,并没有太多的意义。

  1. 只要交叉类型 T 中任意一个成员类型包含了属性签名 M,那么交叉类型 T 也包含属性签名 M。
interface A {
  a: number;
  b: string;
}

interface B {
  a: number;
  c: string;
}

// {
//  a: number;
//  b: string;
//  c: string;
// }
type C = A & B;
  1. 对于交叉类型的属性签名,其类型为所有成员类型中该属性类型的交叉类型。
interface A {
  x: {
    a: number;
  }
}

interface A {
  x: {
    b: number;
  }
}

// {
//  x: {
//    a: number;
//    b: number;
//  }
// }
type C = A & B;
  1. 若交叉类型的属性签名 M 在所有成员类型中都是可选属性,那么该属性签名在交叉类型中也是可选属性。否则,属性签名 M 是一个必选属性。
interface A {
  a: number;
  b?: string;
}

interface B {
  a?: number;
  b?: string;
}

// {
//  a: number;
//  b?: string;
// }
type C = A & B;
  1. 若交叉类型的成员类型中含有调用签名或构造签名,那么这些调用签名和构造签名将以成员类型的先后顺序合并到交叉类型中。
interface A {
  (x: number): number;
}

interface B {
  (x: string): number;
}

// {
//  (x: number): number;
//  (x: string): number;
// }
type C = A & B;

// {
//  (x: string): number;
//  (x: number): number;
// }
type D = B & A;

因为合并时,调用签名或构造签名是按顺序合并的,函数重载是按声明顺序判断的,如果匹配了某一个声明,不管后面的是否匹配, 都不会去进行判断了。所以交叉类型是不满足交换律的。 A & B === B & A不是在所有的情况都成立的。

TypeScript 类型编程

interface A {
  (x: boolean): string;
}

interface B {
  (x: boolean): '1';
}

const a: A & B = (x) => '1';
const b: B & A = (x) => '1';

// c 的类型是 string
const c = a(true);

// d 的类型是 1
const d = b(true);

当遇到调用签名或构造签名的时候,使用 & 一定要注意,因为不一样的顺序可能会有不同的结果。

keyof

keyof 操作符可以用于获取某种类型的所有键,其返回类型是联合类型。

interface Person {
  name: string;
  age: number;
  phone: number;
}

// "name" | "age" | "phone"
type A = keyof Person;

除了 interface 外,keyof 也可以用于操作类。

class Person 
  name: string = "";
}

// "name"
type A = keyof Person;

keyof 操作符不只支持 interface 和类,它可以支持任何类型。

// "valueOf"
type A = keyof boolean;

//"toString" | "toFixed" | "toExponential" | ...
type B = keyof number;

运算法则

  1. 交换律 (交叉类型不满足)
// A 与 B 是等价的
type A = string | number;
type B = number | string;
  1. 结合律 (交叉类型和联合类型都是满足的)
// A 与 B 是等价的
type A = (string | number) | boolean;
type B = string | (number | boolean);

// C 与 D 是等价的
type C = (string & number) & boolean;
type D = string & (number & boolean);
  1. 类型合并
  • 联合类型,假设有联合类型 U = Parent | Child,如果ChildParent 的子类型,那么可以将类型成员 Child 从联合类型 U 中消去。最后,联合类型U的结果类型为 U = Parent
  • 交叉类型,假设有联合类型 U = Parent & Child,如果 ChildParent 的子类型,那么可以将类型成员 Parent 从联合类型 U 中消去。最后,联合类型U的结果类型为 U = Child
// A 与 B 是等价
type A = boolean | true;
type B = boolean;

// C 与 D 等价
type C = boolean & true;
type D = true;
  1. 分配律,当表示交叉类型的 & 符号与表示联合类型的 | 符号同时使用时,& 符号具有更高的优先级。所以交叉类型和联合类型满足分配律 A & (B | C) = A & B | A & C
  2. 对偶律 (和对偶律没啥关系,只是为了好理解)

TypeScript 类型编程

interface X {
  a: number;
  b: string;
}

interface Y {
  a: number;
  c: string;
}

// A 与 B 等价
type A = keyof (X | Y);
type B = keyof X & keyof Y;

// C 与 D 等价
type C = keyof (X & Y);
type D = keyof X | keyof Y;

变量

类型别名为类型创建新名称。类型别名有时类似于接口,但可以命名原语、联合、元组和任何其他你手工编写的类型。声明并赋值变量就是使用 type 关键字为类型起一个别名。

type A = number;
type B = A;

只能声明并且赋值,不能单独声明或单独赋值。有点像 JavaScript 中的 const,与 const 不同的是 type 存在变量提升

// 这样也是正确的
type B = A;
type A = 1;

函数

函数其实是泛型的一种。声明函数其实也就是声明一个泛型。所以我们的函数也可以使用泛型的所有特性,例如泛型约束

函数声明

type Func<T> = T;

类型编程中的函数声明与 JavaScript 中的箭头函数特别相似,下面是一张类型编程中的函数声明与箭头函数的对比。

TypeScript 类型编程

函数调用

函数调用与 JavaScript 中的函数调用更加相似,只不过是将 () 换成了 <>

// number
type A = Func<number>;

函数参数

函数参数与 JavaScript 中的函数有一点点的不同是,泛型约束可以用于函数参数。

interface Length {
  length: number;

}

type Func<T extends Length> = T['length'];

默认参数

type Func<T = number> = T;

默认参数和 JavaScript 中函数的默认参数规则是相同的,没有默认值的参数不允许出现在有默认值的参数之后

函数递归

这里要涉及的其他的很多内容,等我们看完这篇文章之后,再回来看这里的代码。

TypeScript 类型编程

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

条件语句 Conditional Types

终于到我们我们基础篇的最后一节了。文章开头也已经介绍过了条件类型,其语法实际上就是三元表达式 T extends U ? X : Y

interface Animal {
  live(): void;
}

interface Dog extends Animal {
  woof(): void;
}

// number
type A = Dog extends Animal ? number : string;

// string
type B = RegExp extends Animal ? number : string;

分布式条件

还记得文章开始疑问吗?为什么 Exclude 可以正常工作?其原因就是分布式条件:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。

  • 裸类型参数,没有额外被接口或类型别名包裹过的;
  • 分发至联合类型: ( A | B) extends T ? X : Y 相当于 (A extends T ? X : Y) | (B extends T ? X : Y)
type Type1<T> = T extends boolean ? 1 : 2;
type Type2<T> = Array<T> extends Array<boolean> ? 1 : 2;
type Type3<T> = T extends boolean[] ? 1 : 2;

// 等同于 Type1<number> | Type1<boolean>
type A = Type1<number | boolean>; // 1 | 2

// 等同于 Array<number | boolean> extends Array<boolean>
type B = Type2<number | boolean>; // 2

// 等同于 Type3<number[]> | Type3<boolean[]>
type C = Type3<number[] | boolean[]>; // 1 | 2

Type1Type3中的 T 都是裸类型参数,所以实参中的联合类型会分发至联合类型。再回来看 Exclude 的实现:

type Exclude<T, U> = T extends U ? never : T;

Exclude <"a" | "b" | "c", "a"> === Exclude <"a", "a"> | Exclude <"b", "a"> | Exclude <"c", "a"> === never | "b" | "c"。通过运算法则中的类型合并,never 是所有类型的子类型,所以 never 会被忽略掉。即最后的结果是 "b" | "c"。有没有恍然大悟的感觉?

TypeScript 类型编程

联合类型分发在类型编程中会经常出现,也是类型编程与 JavaScript 编程最大的区别之一。在类型编程中,没有循环,但是有时可以通过联合类型分发实现循环。

Infer

infer 表示在 extends 条件语句中待推断的类型变量。

type PromiseType<T> = T extends Promise<infer R> ? R : any;

在这个条件语句 T extends Promise<infer R> ? R : any 中,infer R 表示待推断的 Promise 的类型。如果 T 能赋值给 Promise<infer R>,则结果是 R,否则返回为 any

type PromiseType<T> = T extends Promise<infer R> ? R : any;

// number
type A = PromiseType<Promise<number>>;

我们可以把 infer R 看成数学里设了一个未知数,例如上面的例子,转化成数学题就是:已知 T = Promise<number>T = Promise<infer R>,求未知数 infer R

ReturnType 的实现

ReturnType<T> 的作用是用于获取函数 T 的返回类型:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

类型相关操作

我们终于将类型编程的基础学完了,当看到 Partial 的实现,type Partial<T> = { [P in keyof T]?: T[P]; } 又开始怀疑人生了。按照实现,如果传入一个数组类型,返回的应该是个对象类型,但事实上,返回的却是数组。

// 这里起名为 MyPartial,防止与系统函数重名
type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

type SomeTuple = [number, string?];

// 结果是 [number?, string?]
type A = MyPartial<SomeTuple>;

可是如果我们换一种写法:

// 结果是 { 0?: number, 1?: string, length?: number...}
type B = { [P in keyof SomeTuple]?: SomeTuple[P] }

为什么看似相同的两种写法,一种定义成了函数,一种直接运算,其结果怎么天差地别?

TypeScript 类型编程

这是继联合类型分发第二个反直觉的特性--同态映射对象类型。我们会在后面慢慢介绍,先让我们系统的学习一下一些常见类型的操作。

对象类型字面量

type A = {
  name: string;
}

众所众知,对象类型字面量interface 是很像的,有时可以相互代替的,所以我们也可以对 interface 做下列操作。

索引访问 Indexed Access Types

我们可以使用索引访问类型来查找另一种类型上的特定属性:

interface Person {
  name: string;
  age: number;
  phone: number;
  fly: never;
}

// 属性名
type PersonAge = Person['age']; // number

// 当索引是联合类型也会发生联合类型分发
type I1 = Person['age' | 'name']; // number | string
type I2 = Person[keyof Person]; // number | string
type I2 = Person['age' | 'name' | 'phobe' | 'fly'];

映射类型 Mapped Types

映射类型是一种泛型类型,它使用 Key 的联合(通常通过 keyof 创建)来遍历键来创建类型:

// ---------------------------------
// | mapping 1、联合类型
// ---------------------------------
type Property = 'name' | 'age' | 'phone';

// type Person = {
//   name: string;
//   age: string;
//   phone: string;
// }
type Person = {
  [key in Property]: string;
}

// clone Person 接口
type NewPerson = {
  [key in keyof Person]: Person[key];
}

// ---------------------------------
// | mapping 2、基础类型
// ---------------------------------
// 这两种声明方式是等效的
type StringKey1 = {
  [key in string]: unknown;
}

// type StringKey1 = Record<string unknown>

type StringKey2 = {
  [key: string]: unknown;
}

// ---------------------------------
// | mapping 3、枚举类型
// ---------------------------------
enum Letter {
  A = 0,
  B = 1,
  C = 2,
}

// type LetterMap = {
//     0: string;
//     1: string;
//     2: string;
// }
type LetterMap = {
  [key in Letter]: string;
}

// type LetterKeyMap = {
//     A: string;
//     B: string;
//     C: string;
// }
type LetterKeyMap = {
  [key in (keyof typeof Letter)]: string;
}

Record 的实现

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

映射修饰符

我们可以通过在前面加上 -+ 来移除或添加 readonly and ? 修饰符。如果不加前缀,那么就假设为 +

Partial 的实现

Partial<T> 的作用就是将某个类型里的属性全部变为可选项 ?

type Partial<T> = {
  [P in keyof T]?: T[P];
};

Required 的实现

Required<T> 的作用就是将某个类型里的属性全部变为必须的。

type Required<T> = {
  [P in keyof T]-?: T[P];
};

Pick 的实现

Pick<T, K extends keyof T> 的作用是将某个类型中的子属性挑出来,变成包含这个类型部分属性的子类型。

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// 基于值的pick
type PickByValueType<T, V> = Pick<
  T,
  {
    [K in keyof T]-?: T[K] extends V ? K : never
  }[keyof T]
>;

终于到同态映射对象类型出场了。

TypeScript 类型编程

在这些例子里,属性列表是 keyof T 且结果类型是 T[P] 的变体。 这是使用通用映射类型的一个好模版。 因为这类转换是同态的,映射只作用于 T 的属性而没有其它的。 编译器知道在添加任何新属性之前可以拷贝所有存在的属性修饰符。 ReadonlyPartialPick是同态的,但 Record不是。 因为 Record并不需要输入类型来拷贝属性,所以它不属于同态:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

非同态类型本质上会创建新的属性,因此它们不会从它处拷贝属性修饰符。按照上面的描述,简而言之就是,PartialRequiredPick同态映射对象类型,不管 T 是什么类型,都可以返回其原来的类型。

as

在 Typescript 4.1 及以后的版本中,你可以使用映射类型中的 as 子句重新映射映射类型中的键,你可以利用像模板字面量类型这样的特性来从以前的属性名创建新的属性名:

type Getters<T> = {
  [key in keyof T as `get${Capitalize<string & key>}`]: () => T[key]
};

// type A = {
//   getName: () => string;
//   getAge: () => number;
//   getPhone: () => number;
//   getFly: () => never;
// }
type A = Getters<Person>;

数组和元组

索引访问

type A = ['a', 'b', 'c']

// 使用基础类型, 访问数组的全部元素
type B = A[number] // a | b | c

// 访问数组上的属性
type C = A['length'] // 3

// 访问数组上的一个元素
type D = A[1]; // "b"

解构

type Contact<T extends any[], U extends any[]> = [...T, ...U];

type A = Contact<[number], [string]>; // [number, string]

Infer

// 获取元组第一个元素
type First<T extends any[]> = T extends [infer F, ...infer R] ? F : never;

// 获取元组最后一个元素
type Last<T extends any[]> = T extends [...infer F, infer R] ? F : never;

Includes 的实现

type Includes<T extends any[], U> = T extends [infer F, ...infer R]
  ? F extends U ? true : Includes<R, U>
  : false;

type A =  Includes<[string, number], boolean>; // false

type B =  Includes<[string, number], number>; // ture

模板字符串 Template Literal Types

模板文字类型建立在字符串文字类型的基础上,并且能够通过联合扩展成许多字符串。

type A = 'a';

// ab
type AB = `${A}b`

联合类型

type A = 'en' | 'ja';

// 这里也会存在联合类型分发
// 'en_lang' | 'ja_lang'
type B = `${A}_lang`;

Infer

type StringToUnion<T extends string> = 
    T extends `${infer L}${infer R}` ? L | StringToUnion<R> : never

// "h" | "e" | "l" | "l" | "o"
type Hello = StringToUnion<"hello">

总结

学习编程,光看是远远不够的。如果大家对 TypeScript 的类型编程有兴趣可以到 Type Challenges 去练习和学习。