一起来做类型体操吧!😊

一起来做类型体操吧!😊

类型重要吗?

长话短说,类型很重要。

类型的重要性

静态检查

在编程中,类型往往是我们的第一道防线,它可以帮助我们在编译阶段就发现一些潜在的问题,避免一些不必要的错误。在过去,由于 JavaScript 是弱类型、解释型语言,所以在编译阶段无法发现一些类型相关的问题,这就需要我们在运行时进行一些类型检查,这样就会增加一些不必要的开销。而 TypeScript 则是由微软推出的、基于 JavaScript 的强类型语言,它可以在编译阶段就发现一些类型相关的问题,这样就可以避免一些不必要的错误。

当然也不是说用 JavaScript 就一定会出问题,但这要求编程者有更高的责任心和编程能力,能够在编码阶段就提前规避问题,但对于大型项目来说,这是不现实的。不要完全相信任何人的代码,即使是自己的代码。因为人是会犯错的,需要加以约束。在 Web 应用开发过程中,JavaScript 代码如果访问了一个空对象的字段则会导致异常,如果程序有限定错误边界,那么这个错误可能会被忽略,但是如果没有限定错误边界,那么这个错误可能会导致程序崩溃,也就是页面白屏。而使用 TypeScript 配合 IDE 的类型检查、其他静态检查工具,可以在编码、编译和代码合并时就发现并修复这些问题。

类型即文档

另外,用弱类型语言编写的项目一旦涉及到多人协作(甚至是对于现在的自己和过去的自己来说也是如此),就会变得难以维护和协作,因为弱类型语言无法提供足够的信息,所以在多人协作时,很容易出现一些问题。而强类型语言则可以提供足够的信息,帮助我们更好地理解代码,提高代码的可维护性。所以我也认为“类型即文档”。

现在很多语言都有相关工具可以通过类型信息来生成文档,例如 openapi-generator、TypeDoc、JSDoc 等等,通过代码自动生成文档可以帮助开发者省去很多写文档的时间,同时也可以极大程度地提高交流效率。

脚本语言的类型

脚本语言通常以小巧方便为优势,在最初设计时没有考虑到类型检查的问题,所以在设计时没有引入类型系统,这样可以减少一些不必要的开销。但越来越多人用脚本语言来开发大型项目,这时就需要引入类型系统来帮助我们更好地维护代码。例如纯粹的 JavaScript 有 JSDoc 和 Flow、Python 有 MyPy、Ruby 有 Sorbet、PHP 有 Hack、Lua 有 Typed Lua 等等。

JavaScript

在纯粹的 JavaScript 中,我们可以使用 JSDoc 来对函数的参数和返回值进行类型注解,这样可以起到类型标注的作用,这无法起到类型检查的作用,但是可以使得部分的 IDE 在编码时提供更好的提示。

test-js-docs.js
1
2
3
4
5
6
7
/**
* @param {number} a
* @param {number} b
*/
function add(a, b) {
return a + b;
}

Facebook (现在的 Meta) 的 Flow 是一个 JavaScript 静态类型检查工具,它有着和 TypeScript 类似的功能(甚至是极其类似的语法,TypeScript 的语法设计应该很大程度上借鉴了 Flow 的设计),可以在编码阶段就发现一些类型相关的问题。

test-flow.js
1
2
3
4
// @flow
function add(a: number, b: number): number {
return a + b;
}

Python

Python 3.5 开始引入了类型提示,可以通过 typing 模块来对函数的参数和返回值进行类型注解,这样可以起到类型标注的作用,这无法起到类型检查的作用,但是可以使得部分的 IDE 在编码时提供更好的提示。

python-typing.py
1
2
3
4
5
6
7
from typing import List

def add(a: int, b: int) -> int:
return a + b

def concat(a: List[int], b: List[int]) -> List[int]:
return a + b

Python 中的类型系统要比 TypeScript 弱一些,只能起到标注和提示的作用,无法进行类型检查,但是可以通过一些工具来进行类型检查,例如 mypy。我极为乐于看到的是,目前有些 Python Web 框架已经在利用类型提示来生成文档,例如 FastAPI。

什么是类型体操?

在 TypeScript 内置的类型中,有很多工具类型,比如 PartialRequiredReadonlyRecordReturnTypeParameters 等等,这些工具类型可以帮助我们更好地操作类型。所谓类型体操,就是仅基于 TypeScript 的各种内置的类型和类型操作符如infer, typeof, keyof, extends 等来实现一些工具类型,使用 TypeScript 的类型推导能力来运行具体的逻辑、消除 IDE 的警告、提高代码的可读性等。Python 的类型体操也是类似的,只不过 Python 的系统类型较弱,不能够实现像 TypeScript 那样灵活的类型操作。

类型挑战题库

说到这里不得不提一下 type-challenges 这个 Github 仓库,其中有很多关于 TypeScript 的类型挑战,有需要时可以进行检索查阅,有空闲时间的话也可以到其中进行解题挑战,帮助自己更好的掌握 TypeScript 中的类型系统和编写类型的技巧。推荐在完整了解和使用过 TypeScript 中的基础工具类型和类型操作符之后再上手进行挑战,不然会有较高难度。当然最好不要过于钻牛角尖,毕竟类型体操有千千万万种题面,且为了设计成挑战题目,有些需求是不切实际的,所以不要过于纠结于题目的细节(有些题目为了难而难),而是要关注练习和学习的初衷。

一些例子和个人理解

DeepReadonly

这道题目的要求是实现一个 DeepReadonly 工具类型,使得所有的属性都变成只读的,包括嵌套的属性。其中有个测试用例是这样的:

deep-readonly-case.ts
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
37
38
39
40
41
42
43
44
45
46
type X1 = {
a: () => 22;
b: string;
c: {
d: boolean;
e: {
g: {
h: {
i: true;
j: "string";
};
k: "hello";
};
l: [
"hi",
{
m: ["hey"];
}
];
};
};
};
type Expected1 = {
readonly a: () => 22;
readonly b: string;
readonly c: {
readonly d: boolean;
readonly e: {
readonly g: {
readonly h: {
readonly i: true;
readonly j: "string";
};
readonly k: "hello";
};
readonly l: readonly [
"hi",
{
readonly m: readonly ["hey"];
}
];
};
};
};

type cases = [Expect<Equal<DeepReadonly<X1>, Expected1>>];

如果仅仅是递归地将属性变成只读的话,那么这个题目就太简单了,但是这个题目的难点在于如何处理数组,因为数组是一个特殊的对象,它的属性是数字,而且数组是可变的,所以我们需要将数组的属性也变成只读的,同时也需要将数组的元素变成只读的。这个题目能通过测试用例的解法是这样的,首先判断一个类型是否拓展了 Function,如果是的话就直接返回原类型,否则就递归地将其中的属性变成只读的。

deep-readonly.ts
1
2
3
4
5
6
7
// 能通过测试用例的解法
type DeepReadonly<T> = T extends Function
? T
: { readonly [k in keyof T]: DeepReadonly<T[k]> };

// 我认为正确的写法
type DeepReadonly<T> = { readonly [k in keyof T]: DeepReadonly<T[k]> };

但我认为这个解法有问题,例如上面的用例片段中 X1['b'] 是一个 string 类型的字段,我判断 X1['b'] 是否是 Function 类型时会返回 false,但因为这个写法通过了测试用例,说明在执行判断时 DeepReadonly<X1['b']> = DeepReadonly<string> = string,这是不对的,因为 string extends Function 的结果为 false,就不应该会返回原来的类型。但如果去掉了这个终止条件的判断,仅通过递归的调用,如何达到终止条件呢?

原来 never 类型是 TypeScript 中的底类型,它是所有类型的子类型,所以 never 类型可以赋值给任何类型,任何类型都无法赋值给 never。所以我们可以通过 never 类型来终止递归的调用,这样就可以达到终止条件。

recursive-exit-point.ts
1
2
3
4
type cases = [
Expect<Equal<DeepReadonly<never>, never>>
// 该断言是通过的,任何涉及 never 的类型都会相等,这也是隐藏的递归终止条件
];

TupleToObject

这道题目要求将元组转换为键值相等的对象,解法中利用了元组可以通过数字下标来访问属性的特点:

tuple-to-object.ts
1
2
3
4
5
6
type TupleToObject<T extends readonly (keyof any)[]> = {
[k in T[number]]: k;
};

type tuple = readonly (keyof any)[]; // readonly (keyof any)[] 可以表示任何类型的元组
type tuple = readonly (string | number | symbol)[];

MyAwaited

这道题目要求实现一个异步函数递归等待的工具类,这个题目的难点在于如何递归地等待 PromiseLike 类型的结果,通过 infer 关键字来获取 PromiseLike 的结果类型,然后判断这个结果类型是否 也是 PromiseLike 类型,如果是的话就继续递归地调用 MyAwaited,否则就返回结果类型。在 extends 类型判断语句中能够使用 infer 关键字来获取类型,这是 TypeScript 中的一个高级特性,能够玩出很多花样。

my-awaited.ts
1
2
3
4
5
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer U>
? U extends PromiseLike<any>
? MyAwaited<U>
: U
: never;

Chainable

这道题目要求实现一个链式调用设置对象参数的工具类,而且要求不能重复设置相同的键。题目的难点在于如何判断一个键是否已经被设置过,通过 K extends keyof T ? never : K 可以判断一个键是否已经被设置过,如果已经被设置过就返回 never 类型(并导致调用时报错),否则就返回原本的键类型。

chainable.ts
1
2
3
4
5
6
7
type Chainable<T = {}> = {
option: <K extends string, V>(
key: K extends keyof T ? never : K,
value: V
) => Chainable<Omit<T, K> & Record<K, V>>;
get: () => T;
};

项目中的案例

类型体操并不完全是为了脑筋急转弯,它在实际的项目中也是可以发挥作用的,适当地进行类型编程可以在适当的场合节省较多的工作量。

需求背景

在我参与的项目几个月前引入 OpenAPI Generator typescript-axios 后涉及了 API 请求响应类型的变化。具体来说生成的客户端代码中 Axios 请求方法返回的是 AxiosResponse,而原有代码中的请求方法返回的都是 Promise。这一转变要求在所有前端组件中都需要修改处理 API 返回结果的逻辑,这是一个非常繁琐的工作,因为项目中有很多组件,而且每个组件中都有很多请求方法,如果工作量过大会导致协作者不愿意引入新的工具 OpenAPI Generator。所以我想到了通过包装一个 TypeScript 转换器来解决这个问题。既要保证生成的 API 工厂类型能够在 IDE 中得到正确的代码提示,又要保证转换后的代码能够正确地将返回结果从 AxiosResponse 转换为 Promise 类型。

解决方案

wrapper.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ClusterApiFactory, Configuration } from "./generated/index";

const config = new Configuration({});

type factoryFunction<T> = (
configuration?: Configuration | undefined,
basePath?: string | undefined,
axios?: AxiosInstance | undefined
) => T;

const wrapper = <T>(
f: factoryFunction<T>,
...args: Parameters<factoryFunction<T>>
): PromiseWrapperType<T> => {
return f(...args) as any;
};

type PromiseWrapperType<T> = {
[K in keyof T]: T[K] extends (...args: infer P) => AxiosPromise<infer R>
? (...args: P) => Promise<R>
: never;
};

export const clusterApi = wrapper(ClusterApiFactory, config);

参考资料

作者

PowerfooI

发布于

2024-07-27

更新于

2024-08-09

许可协议

评论