初探 TypeScript 类型编程

平常我们编写 TypeScript 时,主要会使用类型注解(给变量、函数等加上类型约束),这可以增强代码可读性、避免低级 bug。实际上 TypeScript 的类型系统设计的非常强大,强大到可以单独作为一门编程语言。本文是自己学习 TypeScript 类型编程的一个总结,希望对你有帮助。

开始之前

本文不会对 TypeScript 的基础语法和使用进行说明,你可以参考互联网上提供的优秀资料:

启程

参考 SCIP 中对于编程语言的描述。一门编程语言应该提供以下机制:

  • 基本表达式。用来表示语言所关心的最简单的个体。
  • 组合的方法。从简单的个体出发构造复合的对象。
  • 抽象的方法。能将复合对象封装作为独立单元去使用。

下面我们将以这三个方面为线索来探索 TypeScript 的类型编程。

基本表达式

我们首先来看看类型编程中,定义“变量”的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// string、number、boolean 的值可以作为类型使用,称为 literal type
type LiteralS = 'x';
type LiteralN = 9;
type LiteralB = true;

// 基础类型
type S = string;
// 函数
type F = (flag: boolean) => void;
// 对象
type O = { x: number; y: number; };
// tuple
type T = [string, number];

这里稍微补充下 interface 和 type 的区别。

最主要的区别就是 type 可以进行“类型编程”,interface 不行。

interface 能定义的类型比较局限,就是 object/function/class/indexable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// object
interface Point {
x: number;
y: number;
}
const p: Point = { x: 1, y: 2 };

// function
interface Add {
(a: number, b: number): number;
}
const add: Add = (x, y) => x + y;

// class
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick(): void;
}
const Clock: ClockConstructor = class C implements ClockInterface {
constructor(hour: number, minute: number) { return this; }
tick() {}
}
const c = new Clock(1, 2);
c.tick();

// indexable
interface StringArray {
[index: number]: string;
}
interface NumberObject {
[key: string]: number;
}
const s: StringArray = ['a', 'b'];
const o: NumberObject = { a: 1, b: 2 };

interface 可以被重新“打开”,同名 interface 会自动聚合,非常适合做 polyfill。比如,我们想要在 window 上扩展一些原本不存在的属性:

1
2
3
4
5
interface Window {
g_config: {
locale: 'zh_CN' | 'en_US';
};
}

组合的方法

有了基本表达式,我们来看组合的方法。

| 和 & 操作符

& 表示必须同时满足多个契约,| 表示满足任意一个契约即可。

1
2
3
4
5
6
7
8
type Size = 'large' | 'normal' | 'small';

// never 可以理解为 | 运算的“幺元”,即:x | never = x
type T = 1 | 2 | never; // 1 | 2

type Animal = { name: string };
type Flyable = { fly(): void };
type FlyableAnimal = Animal & Flyable; // { name: string, fly(): void }

keyof 操作符

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Sizes {
large: string;
normal: string;
small: string;
x: number;
}
// 获取对象的属性值
type Size = keyof Sizes; // 'large' | 'normal' | 'small' | 'x'
// 反向获取对象属性的类型
type SizeValue = Sizes[keyof Sizes]; // string | number

// keyof any 可以理解为能作为“索引”的类型
type K = keyof any; // string | number | symbol

抽象的方法

抽象的方法实际上指的就是“函数”。我们来看看类型编程中,“函数”该怎么定义。

1
2
3
4
5
6
7
8
9
// “定义”
type Id<T> = T;
// “调用”
type A = Id<'a'>; // 'a'

// “参数”约束及默认值
type MakePair<T extends string, U extends number = 1> = [T, U];
type P1 = MakePair<'a', 2>; // ['a', 2]
type P2 = MakePair<'x'>; // ['x', 1]

看起来是不是和编程语言里面的函数很相似?这些“函数”的输入(参数)是类型,经过“运算”后,输出是“类型”。接着我们来看看在“函数体”(也就是等号右边)里面除了一些基本操作外,还可以做些其他什么骚操作。

“映射”操作(mapped)

将已有类型转换为一个新的类型,类似 map。返回的新类型一般是对象。

1
2
3
4
5
6
7
8
9
10
type MakeRecord<T extends keyof any, V> = {
[k in T]: V
};
type R1 = MakeRecord<1, number>; // { 1: number }
type R2 = MakeRecord<'a' | 1, string>; // { a: string, 1: string }

type TupleToObject<T extends readonly any[]> = {
[k in T[number]]: k
};
type O = TupleToObject<['a', 'b', 'c']>; // { a: 'a', b: 'b', c: 'c' }

条件——extends

条件类型可以理解为“三元运算”,T extends U ? X : Y,extends 可以类比为“相等”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 只保留string
type OnlyString<T> = T extends string ? T : never;
type S = OnlyString<1 | 2 | true | 'a' | 'b'>; // 'a' | 'b'
// 这里的计算过程大致是:
// 1 | 2 | true | 'a' | 'b' -> never | never | never | 'a' | 'b'
// 根据 x | never = x,最终得到 'a' | 'b'

// 获得对象的函数类型的属性key值
type FunctionPropertyNames<T> = {
[k in keyof T]: T[k] extends Function ? k : never
}[keyof T];
interface D {
id: number;
add(id: number): void;
remove(id: number): void;
}
type P = FunctionPropertyNames<D>; // 'add' | 'remove'
// 这里的计算过程大致是:
// 将 interface 展开:
// {
// id: D['id'] extends Function ? 'id' : never, //-> false
// add: D['add'] extends Function ? 'add' : never, //-> true
// remove: D['remove'] extends Function ? 'remove' : never //-> true
// }['id' | 'add' | 'remove']
// 计算条件类型:
// {
// id: never,
//. add: 'add',
// remove: 'remove'
// }['id' | 'add' | 'remove']
// 根据索引取值:
// never | 'add' | 'remove'
// 根据 never | x = x,最终得到:'add' | 'remove'

“析构“——infer

infer 可以理解为一种“放大镜”机制,可以“捕获”到被“嵌”在各种复杂结构里的类型信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 对象 infer,可以取得对象某个属性值的类型
type ObjectInfer<O> = O extends { x: infer T } ? T : never;
type T1 = ObjectInfer<{x: number}>; // number

// 数组 infer,可以取得数组元素的类型
type ArrayInfer<A> = A extends (infer U)[] ? U : never;
const arr = [1, 'a', true];
type T2 = ArrayInfer<typeof arr>; // number | string | boolean

// tuple infer
type TupleInfer<T> = T extends [infer A, ...(infer B)[]] ? [A, B] : never;
type T3 = TupleInfer<[string, number, boolean]>; // [string, number | boolean]

// 函数 infer,可以取得函数的参数和返回值类型
type FunctionInfer<F> = F extends (...args: infer A) => infer R ? [A, R] : never;
type T4 = FunctionInfer<(a: number, b: string) => boolean>; // [[a: number, b: string], boolean]

// 更多其他的 infer
type PInfer<P> = P extends Promise<infer G> ? G : never;
const p = new Promise<number>(() => {});
type T5 = PInfer<typeof p>; // number

可以发现上面的例子需要使用 infer,是因为我们在“定义”时不知道具体的类型,需要在“调用”时做“推断”。infer 帮我们标注了待推断的类型,最终计算出实际的类型。

嵌套&递归

在“函数体”中,我们其实可以再“调用函数“,形成一种嵌套和递归的机制。

1
2
3
4
5
6
7
8
9
10
// 取函数第一个参数的类型
type Params<F> = F extends (...args: infer A) => any ? A : never;
type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never;
type FirstParam = Head<Params<(name: string, age: number) => void>>; // string

// 递归定义
type List<T> = {
value: T,
next: List<T>
} | null;

尾声

文章写到这里基本就结束了,这篇文章的内容可能在平常的开发中会比较少遇到,但是对于补全自己的 TypeScript 体系、开阔视野还是有所帮助的。如果想更多的来些实战演练,推荐看看这个:https://github.com/type-challenges/type-challenges