一起来做类型体操吧!😊
类型重要吗?
长话短说,类型很重要。
类型的重要性
静态检查
在编程中,类型往往是我们的第一道防线,它可以帮助我们在编译阶段就发现一些潜在的问题,避免一些不必要的错误。在过去,由于 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 在编码时提供更好的提示。
1 | /** |
Facebook (现在的 Meta) 的 Flow 是一个 JavaScript 静态类型检查工具,它有着和 TypeScript 类似的功能(甚至是极其类似的语法,TypeScript 的语法设计应该很大程度上借鉴了 Flow 的设计),可以在编码阶段就发现一些类型相关的问题。
1 | // @flow |
Python
Python 3.5 开始引入了类型提示,可以通过 typing
模块来对函数的参数和返回值进行类型注解,这样可以起到类型标注的作用,这无法起到类型检查的作用,但是可以使得部分的 IDE 在编码时提供更好的提示。
1 | from typing import List |
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
工具类型,使得所有的属性都变成只读的,包括嵌套的属性。其中有个测试用例是这样的:
1 | type X1 = { |
如果仅仅是递归地将属性变成只读的话,那么这个题目就太简单了,但是这个题目的难点在于如何处理数组,因为数组是一个特殊的对象,它的属性是数字,而且数组是可变的,所以我们需要将数组的属性也变成只读的,同时也需要将数组的元素变成只读的。这个题目能通过测试用例的解法是这样的,首先判断一个类型是否拓展了 Function,如果是的话就直接返回原类型,否则就递归地将其中的属性变成只读的。
1 | // 能通过测试用例的解法 |
但我认为这个解法有问题,例如上面的用例片段中 X1['b']
是一个 string 类型的字段,我判断 X1['b']
是否是 Function 类型时会返回 false,但因为这个写法通过了测试用例,说明在执行判断时 DeepReadonly<X1['b']>
= DeepReadonly<string>
= string
,这是不对的,因为 string extends Function
的结果为 false,就不应该会返回原来的类型。但如果去掉了这个终止条件的判断,仅通过递归的调用,如何达到终止条件呢?
原来 never
类型是 TypeScript 中的底类型,它是所有类型的子类型,所以 never
类型可以赋值给任何类型,任何类型都无法赋值给 never。所以我们可以通过 never
类型来终止递归的调用,这样就可以达到终止条件。
1 | type cases = [ |
TupleToObject
这道题目要求将元组转换为键值相等的对象,解法中利用了元组可以通过数字下标来访问属性的特点:
1 | type TupleToObject<T extends readonly (keyof any)[]> = { |
MyAwaited
这道题目要求实现一个异步函数递归等待的工具类,这个题目的难点在于如何递归地等待 PromiseLike 类型的结果,通过 infer
关键字来获取 PromiseLike 的结果类型,然后判断这个结果类型是否 也是 PromiseLike 类型,如果是的话就继续递归地调用 MyAwaited
,否则就返回结果类型。在 extends 类型判断语句中能够使用 infer
关键字来获取类型,这是 TypeScript 中的一个高级特性,能够玩出很多花样。
1 | type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer U> |
Chainable
这道题目要求实现一个链式调用设置对象参数的工具类,而且要求不能重复设置相同的键。题目的难点在于如何判断一个键是否已经被设置过,通过 K extends keyof T ? never : K
可以判断一个键是否已经被设置过,如果已经被设置过就返回 never
类型(并导致调用时报错),否则就返回原本的键类型。
1 | type Chainable<T = {}> = { |
项目中的案例
类型体操并不完全是为了脑筋急转弯,它在实际的项目中也是可以发挥作用的,适当地进行类型编程可以在适当的场合节省较多的工作量。
需求背景
在我参与的项目几个月前引入 OpenAPI Generator typescript-axios 后涉及了 API 请求响应类型的变化。具体来说生成的客户端代码中 Axios 请求方法返回的是 AxiosResponse,而原有代码中的请求方法返回的都是 Promise。这一转变要求在所有前端组件中都需要修改处理 API 返回结果的逻辑,这是一个非常繁琐的工作,因为项目中有很多组件,而且每个组件中都有很多请求方法,如果工作量过大会导致协作者不愿意引入新的工具 OpenAPI Generator。所以我想到了通过包装一个 TypeScript 转换器来解决这个问题。既要保证生成的 API 工厂类型能够在 IDE 中得到正确的代码提示,又要保证转换后的代码能够正确地将返回结果从 AxiosResponse 转换为 Promise 类型。
解决方案
1 | import { ClusterApiFactory, Configuration } from "./generated/index"; |