类型重要吗?
长话短说,类型很重要。
类型的重要性
静态检查
在编程中,类型往往是我们的第一道防线,它可以帮助我们在编译阶段就发现一些潜在的问题,避免一些不必要的错误。在过去,由于 JavaScript 是弱类型、解释型语言,所以在编译阶段无法发现一些类型相关的问题,这就需要我们在运行时进行一些类型检查,这样就会增加一些不必要的开销。而 TypeScript 则是由微软推出的、基于 JavaScript 的强类型语言,它可以在编译阶段就发现一些类型相关的问题,这样就可以避免一些不必要的错误。
当然也不是说用 JavaScript 就一定会出问题,但这要求编程者有更高的责任心和编程能力,能够在编码阶段就提前规避问题,但对于大型项目来说,这是不现实的。不要完全相信任何人的代码,即使是自己的代码。因为人是会犯错的,需要加以约束。在 Web 应用开发过程中,JavaScript 代码如果访问了一个空对象的字段则会导致异常,如果程序有限定错误边界,那么这个错误可能会被忽略,但是如果没有限定错误边界,那么这个错误可能会导致程序崩溃,也就是页面白屏。而使用 TypeScript 配合 IDE 的类型检查、其他静态检查工具,可以在编码、编译和代码合并时就发现并修复这些问题。
类型即文档
另外,用弱类型语言编写的项目一旦涉及到多人协作(甚至是对于现在的自己和过去的自己来说也是如此),就会变得难以维护和协作,因为弱类型语言无法提供足够的信息,所以在多人协作时,很容易出现一些问题。而强类型语言则可以提供足够的信息,帮助我们更好地理解代码,提高代码的可维护性。所以我也认为“类型即文档”。
现在很多语言都有相关工具可以通过类型信息来生成文档,例如 openapi-generator、TypeDoc、JSDoc 等等,通过代码自动生成文档可以帮助开发者省去很多写文档的时间,同时也可以极大程度地提高交流效率。
脚本语言的类型
脚本语言通常以小巧方便为优势,在最初设计时没有考虑到类型检查的问题,所以在设计时没有引入类型系统,这样可以减少一些不必要的开销。但越来越多人用脚本语言来开发大型项目,这时就需要引入类型系统来帮助我们更好地维护代码。例如纯粹的 JavaScript 有 JSDoc 和 Flow、Python 有 MyPy、Ruby 有 Sorbet、PHP 有 Hack 等等。
JavaScript
在纯粹的 JavaScript 中,我们可以使用 JSDoc 来对函数的参数和返回值进行类型注解,这样可以起到类型标注的作用,这无法起到类型检查的作用,但是可以使得部分的 IDE 在编码时提供更好的提示。
/**
* @param {number} a
* @param {number} b
*/
function add(a, b) {
return a + b;
}
Facebook (现在的 Meta) 的 Flow 是一个 JavaScript 静态类型检查工具,它有着和 TypeScript 类似的功能(甚至是极其类似的语法,TypeScript 的语法设计应该很大程度上借鉴了 Flow 的设计),可以在编码阶段就发现一些类型相关的问题。
// @flow
function add(a: number, b: number): number {
return a + b;
}
Python
Python 3.5 开始引入了类型提示,可以通过 typing 模块来对函数的参数和返回值进行类型注解,这样可以起到类型标注的作用,这无法起到类型检查的作用,但是可以使得部分的 IDE 在编码时提供更好的提示。
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 内置的类型中,有很多工具类型,比如 Partial、Required、Readonly、Record、ReturnType、Parameters 等等,这些工具类型可以帮助我们更好地操作类型。所谓类型体操,就是仅基于 TypeScript 的各种内置的类型和类型操作符如infer, typeof, keyof, extends 等来实现一些工具类型,使用 TypeScript 的类型推导能力来运行具体的逻辑、消除 IDE 的警告、提高代码的可读性等。Python 的类型体操也是类似的,只不过 Python 的系统类型较弱,不能够实现像 TypeScript 那样灵活的类型操作。
类型挑战题库
说到这里不得不提一下 type-challenges 这个 Github 仓库,其中有很多关于 TypeScript 的类型挑战,有需要时可以进行检索查阅,有空闲时间的话也可以到其中进行解题挑战,帮助自己更好的掌握 TypeScript 中的类型系统和编写类型的技巧。推荐在完整了解和使用过 TypeScript 中的基础工具类型和类型操作符之后再上手进行挑战,不然会有较高难度。当然最好不要过于钻牛角尖,毕竟类型体操有千千万万种题面,且为了设计成挑战题目,有些需求是不切实际的,所以不要过于纠结于题目的细节(有些题目为了难而难),而是要关注练习和学习的初衷。
一些例子和个人理解
DeepReadonly
这道题目的要求是实现一个 DeepReadonly 工具类型,使得所有的属性都变成只读的,包括嵌套的属性。其中有个测试用例是这样的:
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,如果是的话就直接返回原类型,否则就递归地将其中的属性变成只读的。
// 能通过测试用例的解法
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 类型来终止递归的调用,这样就可以达到终止条件。
type cases = [
Expect<Equal<DeepReadonly<never>, never>>
// 该断言是通过的,任何涉及 never 的类型都会相等,这也是隐藏的递归终止条件
];
TupleToObject
这道题目要求将元组转换为键值相等的对象,解法中利用了元组可以通过数字下标来访问属性的特点:
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 中的一个高级特性,能够玩出很多花样。
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 类型(并导致调用时报错),否则就返回原本的键类型。
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 类型。
解决方案
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);