「来源: |前端技术江湖 ID:bigerfe」
作者:林不渡https://juejin.cn/post/6885672896128090125
前言
作为前端开发的趋势之一,TypeScript 正在越来越普及,很多人像我一样写了 TS 后再也回不去了,比如写再小的demo也要用 TS(得益于ts-node[1]),JS 只有在配置文件如Webpack(实际上,接下来肯定会有用TS写配置文件的趋势,如Vite)、ESLint等时才会用到。但同样,也有部分开发者对TS持有拒绝的态度,如nodemon的作者就曾表示自己从来没有使用过TS(见 #1565[2])。但同样还有另外一部分人认为TS学习成本太高,所以一直没有开始学习的决心。
然而严谨的来说,TS 的学习成本实际上并不高,我认为它可以被分成两个部分:
预实现的 ES 提案,如 装饰器(我之前的一篇文章 走近 MidwayJS:初识 TS 装饰器与 IoC 机制[3] 中讲了一些关于 TS 装饰器的历史, 有兴趣的可以看看), 可选链?. ,空值合并运算符??(和可选链一起在TypeScript3.7[4]中引入),类的私有成员private等。除了部分极端不稳定的语法(说的就是你,装饰器)以外,大部分的TS实现实际上就是未来的 ES 语法。
对于这一部分来说,无论你先前是只学习过 JS(就像我一样),还是有过 Java、C#的使用经历,都能非常快速地上手,这也是实际开发中使用最多的部分,毕竟和另一块-类型编程比起来,还是这一部分更接地气。
类型编程,无论是一个普通接口(interface),还是密密麻麻的T extends SomeType ,或者是各种奇奇怪怪的工具类型(Partial、Required等),其实都属于类型编程的范畴。这一块对代码的功能层面没有任何影响,即使你一行代码十个any,遇到类型错误就@ts-ignore,代码该咋样还是咋样。
然而这也就是类型编程一直不受到太多重视的原因:相比于语法,它会带来代码量大大增多(类型定义可能接近甚至超过业务代码量),上手成本较高等问题,但好处也是显而易见的,那就是类型安全,如果你所在的团队使用Sentry或是类似的监控平台,对于JS代码来说最常见的错误就是Cannot read property 'xxx' of undefined、undefined is not a function这种(如果有兴趣了解更多,可以阅读top-10-javascript-errors[5])。虽然TS不可能把这个错误直接完全抹消,但也能解决十之八九了。
另外一个特点是,在类型编程这一方面上,假设你花费 1 单位脑力使用基础的 TS 以及简单的类型编程(即interface、type等),你就能够获得 5 个单位的回馈。但接下来,有可能你花费 10 个单位脑力,也只能再获得 2 个单位的回馈。所以类型编程往往不会受到过多重视。另外一个类型编程不受重视的重要原因则是,实际业务中并不会需要多么苛刻的类型定义,通常只会对接口数据以及应用状态等进行定义。通常是底层框架类库才会需要大量的条件类型、泛型、重载等。
前言铺垫完毕,接下来就进入正文部分。这篇文章的主要面向对象是还没有走出新手村的同学,可以把本文当成你们的新手任务。
推荐在阅读过程中跟着敲一遍文中的代码,毕竟TS的东西我自己几个月没写都能忘个干净。
正文部分包括:
泛型基础索引类型 & 映射类型条件类型 & 分布式条件类型infer 关键字类型守卫 与 is、 in 关键字内置工具类型原理内置工具类型的增强更多通用工具类型泛型 Generic Type
假设我们有这么一个函数:
functionfoo(args: unknown): unknown{ ... }如果它接收一个字符串,返回这个字符串的部分截取。如果接收一个数字,返回这个数字的 n 倍。如果接收一个对象,返回键值被更改过的对象(键名不变)。上面这些场景有一个共同点,即函数的返回值与入参是同一类型.
如果这时候需要类型定义,是否要把unknown替换为string | number | object?这样固然可以,但别忘记我们需要的是 入参与返回值类型相同的效果。这个时候泛型就该登场了,泛型使得代码段的类型定义易于重用(比如后续又多了一种接收布尔值返回布尔值的函数实现),并提升了灵活性与严谨性:
工程层面当然不会写这样的代码了... 但就当个例子看吧:-)
functionfoo
通常泛型只会使用单个字母。如T U K V S等。我的推荐做法是在项目达到一定复杂度后,使用有具体含义的泛型,如BasicSchema。
foo
泛型在箭头函数下的书写:const foo =
除了用在函数中,泛型也可以在类中使用:
class Foo
索引类型与映射类型
在阅读这一部分前,你需要做好思维转变的准备,需要认识到 类型编程实际也是编程。就像你写业务代码的时候常常会遍历一个对象,而在类型编程中我们也会经常遍历一个接口。因此,你可以将一部分编程思路复用过来。我们实现一个简单的函数:
// 假设key是obj键名functionpickSingleValue(obj, key) {return obj[key];}要为其进行类型定义的话,有哪些需要定义的地方?
参数obj参数key返回值这三样之间是否存在关联?
key必然是obj中的键值名之一,一定为string类型返回的值一定是obj 中的键值因此我们初步得到这样的结果:
functionpickSingleValue
interface foo { a: number; b: string;}type A = keyof foo; // "a" | "b"是不是就像Object.keys()?
字面量类型是对类型的进一步限制,比如你的状态码只可能是 0/1/2,那么你就可以写成status: 0 | 1 | 2的形式。字面量类型包括字符串字面量、数字字面量、布尔值字面量。这一类细碎的基础知识会被穿插在文中各个部分进行讲解,以此避免单独讲解时缺少特定场景让相关概念显得过于单调。
还少了返回值,如果你此前没有接触过此类语法,应该会卡住,我们先联想下for...in语法,遍历对象时我们可能会这么写:
const fooObj = { a: 1, b: "1" };for (const key in fooObj) {console.log(key);console.log(fooObj[key]);}和上面的写法一样,我们拿到了 key,就能拿到对应的 value,那么 value 的类型也就不在话下了:
functionpickSingleValue
但这种写法很明显有可以改进的地方:keyof出现了两次,以及泛型 T 应该被限制为对象类型,就像我们平时会做的那样:用一个变量把多处出现的存起来,在类型编程里,泛型就是变量。
functionpickSingleValue
假设现在不只要取出一个值了,我们要取出一系列值,即参数2将是一个数组,成员均为参数1的键名组成:
functionpick
keys: U[] 我们知道 U 是 T 的键名组成的联合类型,那么要表示一个内部元素均是 T 键名的数组,就可以使用这种方式,具体的原理请参见下文的 分布式条件类型章节。T[U][] 它的原理实际上和上面一条相同,首先是T[U],代表参数1的键值(就像Object[Key]),之所以单独拿出来是因为我认为它是一个很好地例子,表现了 TS 类型编程的组合性,你不感觉这种写法就像搭积木一样吗?索引签名 Index Signature
索引签名用于快速建立一个内部字段类型相同的接口,如
interface Foo { [keys: string]: string;}那么接口 Foo 就被认定为字段全部为 string 类型。
等同于Record
值得注意的是,由于 JS 可以同时通过数字与字符串访问对象属性,因此keyof Foo的结果会是string | number。
const o: Foo = {1: "芜湖!",};o[1] === o["1"]; // true
但是一旦某个接口的索引签名类型为number,那么使用它的对象就不能再通过字符串索引访问,如o['1'],将会抛出Element implicitly has an 'any' type because index expression is not of type 'number'错误。
映射类型 Mapped Types
映射类型同样是类型编程的重要底层组成,通常用于在旧有类型的基础上进行改造,包括接口包含字段、字段的类型、修饰符(只读readonly 与 可选?)等等。
从一个简单场景入手:
interface A { a: boolean; b: string; c: number; d: () =>void;}现在我们有个需求,实现一个接口,它的字段与接口 A 完全相同,但是其中的类型全部为 string,你会怎么做?直接重新声明一个然后手写吗?这样就很离谱了,我们可是机智的程序员。
如果把接口换成对象再想想,假设要拷贝一个对象(假设没有嵌套),new 一个新的空对象,然后遍历原先对象的键值对来填充新对象。再回到接口,其实也一样:
type StringifyA
type Clone
你可以把工具类型理解为你平时放在 utils 文件夹下的公共函数,提供了对公用逻辑(在这里则是类型编程逻辑)的封装,比如上面的两个类型接口就是~
先写个最常用的Partial尝尝鲜,工具类型的详细介绍我们会在专门的章节展开:
// 将接口下的字段全部变为可选的type Partial
条件类型 Conditional Types
条件类型的语法实际上就是三元表达式,看一个最简单的例子:
T extends U ? X : Y如果你觉得这里的 extends 不太好理解,可以暂时简单理解为 U 中的属性在 T 中都有。
为什么会有条件类型?可以看到通常条件类型通常是和泛型一同使用的,联想到泛型的使用场景,我想你应该明白了些什么。对于类型无法即时确定的场景,使用条件类型来在运行时动态的确定最终的类型(运行时可能不太准确,或者可以理解为,你提供的函数被他人使用时,根据他人使用时传入的参数来动态确定需要被满足的类型约束)。
条件类型理解起来更直观,唯一需要有一定理解成本的就是 何时条件类型系统会收集到足够的信息来确定类型,也就是说,条件类型有时不会立刻完成判断。
在了解这一点前,我们先来看看条件类型常用的一个场景:泛型约束,实际上就是我们上面的例子:
functionpickSingleValue
以一个使用条件类型作为函数返回值类型的例子:
declarefunctionstrOrNum
只有给出了所需信息(在这里是入参x的类型),才可以完成推导。
const strReturnType = strOrNum(true);const numReturnType = strOrNum(false);同样的,就像三元表达式可以嵌套,条件类型也可以嵌套,如果你看过一些框架源码,也会发现其中存在着许多嵌套的条件类型,无他,条件类型可以将类型约束收拢到非常精确的范围内。
type TypeName
官方文档对分布式条件类型的讲解内容甚至要多于条件类型,因此你也知道这玩意没那么简单了吧~
分布式条件类型实际上不是一种特殊的条件类型,而是其特性之一。先上概念:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上
原文: Conditional types in which the checked type is a naked type parameterare called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation
先提取几个关键词,然后我们再通过例子理清这个概念:
裸类型参数实例化分发到联合类型// 使用上面的TypeName类型别名// "string" | "function"type T1 = TypeName
是不是 get 到了一点什么?我们再看另一个例子:
type Naked
裸类型参数,没有额外被接口/类型别名/奇怪的东西包裹过的,就像被Wrapped包裹后就不能再被称为裸类型参数。实例化,其实就是条件类型的判断过程,就像我们前面说的,条件类型需要在收集到足够的推断信息之后才能进行这个过程。在这里两个例子的实例化过程实际上是不同的,具体会在下一点中介绍。分发至联合类型的过程:对于 TypeName,它内部的类型参数 T 是没有被包裹过的,所以TypeName
infer 关键字
infer是inference的缩写,通常的使用方式是infer R,R表示 待推断的类型。如果说,通常infer不会被直接使用,而是与条件类型一起,被放置在底层工具类型中,用于
看一个简单的例子,用于获取函数返回值类型的工具类型ReturnType:
const foo = (): string => {return"linbudu";};// stringtype FooReturnType = ReturnType
type ReturnType
infer 其实没有特别难消化的知识点,它需要的只是思路的转变,你要理解 延迟推断的概念。
类型守卫 与 is in 关键字 Type Guards
前面的内容可能不是那么符合人类直觉,需要一点时间消化,这一节我们来看点简单(相对)且直观的知识点:类型守卫。
假设有这么一个字段,它可能字符串也可能是数字:
numOrStrProp: number | string;现在在使用时,你想将这个字段的联合类型缩小范围,比如精确到string,你可能会这么写:
exportconst isString = (arg: unknown): boolean =>typeof arg === "string";看看这么写的效果:
functionuseIt(numOrStr: number | string) {if (isString(numOrStr)) {console.log(numOrStr.length); }}
[图片上传失败...(image-1c4856-1655631635280)]
image啊哦,看起来isString函数并没有起到缩小类型范围的作用,参数依然是联合类型。这个时候就该使用is关键字了:
exportconst isString = (arg: unknown): arg is string =>typeof arg === "string";这个时候再去使用,就会发现在isString(numOrStr)为 true 后,numOrStr的类型就被缩小到了string。这只是以原始类型为成员的联合类型,我们完全可以扩展到各种场景上,先看一个简单的假值判断:
exporttype Falsy = false | "" | 0 | null | undefined;exportconst isFalsy = (val: unknown): val is Falsy => !val;是不是还挺有用?这应该是我日常用的最多的类型别名之一了。
也可以在 in 关键字的加持下,进行更强力的类型判断,思考下面这个例子,要如何将 " A | B " 的联合类型缩小到"A"?
class A {public a() {}public useA() {return"A"; }}class B {public b() {}public useB() {return"B"; }}再联想下for...in循环,它遍历对象的属性名,而in关键字也是一样:
functionuseIt(arg: A | B): void{'a'in arg ? arg.useA() : arg.useB();}如果参数中存在a属性,由于A、B两个类型的交集并不包含a,所以这样能立刻缩小范围到A。
再看一个使用字面量类型作为类型守卫的例子:
interface IBoy { name: "mike"; gf: string;}interface IGirl { name: "sofia"; bf: string;}functiongetLover(child: IBoy | IGirl): string{if (child.name === "mike") {return child.gf; } else {return child.bf; }}之前有个小哥问过一个问题,我想很多用 TS 写接口的小伙伴可能都遇到过,即登录与未登录下的用户信息是完全不同的接口,其实也可以使用in关键字解决。
interface ILogInUserProps { isLogin: boolean; name: string;}interface IUnLoginUserProps { isLogin: boolean;from: string;}type UserProps = ILogInUserProps | IUnLoginUserProps;functiongetUserInfo(user: ILogInUserProps | IUnLoginUserProps): string{return'name'in user ? user.name : user.from;}同样的思路,还可以使用instanceof来进行实例的类型守卫,建议聪明的你动手尝试下~
工具类型 Tool Type
这一章是本文的最后一部分,应该也是本文“性价比”最高的一部分了,因为即使你还是不太懂这些工具类型的底层实现,也不影响你把它用好。就像 Lodash 不会要求你每用一个函数都熟知原理一样。这一部分包括TS 内置工具类型与社区的扩展工具类型,我个人推荐在完成学习后记录你觉得比较有价值的工具类型,并在自己的项目里新建一个.d.ts文件(或是/utils/tool-types.ts)存储它。
在继续阅读前,请确保你掌握了上面的知识,它们是类型编程的基础。
内置工具类型
在上面我们已经实现了内置工具类型中被使用最多的一个:
type Partial
去除可选修饰符:-?只读修饰符:readonly去除只读修饰符:-readonly恭喜,你得到了Required和Readonly(去除 readonly 修饰符的工具类型不属于内置的,我们会在后面看到):
type Required
functionpick
type Pick
既然有了Pick,那么自然要有Omit(一个是从对象中挑选部分,一个是排除部分),它和Pick的写法非常像,但有一个问题要解决:我们要怎么表示T中剔除了K后的剩余字段?
Pick 选取传入的键值,Omit 移除传入的键值
这里我们又要引入一个知识点:never类型,它表示永远不会出现的类型,通常被用来将收窄联合类型或是接口,详细可以看 尤大的知乎回答[6], 在这里 我们不做展开介绍。
在类型守卫一节,我们提到了一个用户登录状态决定类型接口的例子,实际上也可以用never实现。
上面的场景其实可以简化为:
// "3" | "4" | "5"type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;Exclude,字面意思看起来是排除,那么第一个参数应该是要进行筛选的,第二个应该是筛选条件!先按着这个思路试试:
用排列组合的思路考虑:"1"在"1" | "2"里面吗("1" extends "1"|"2" -> true)?在啊, 那让它爬,"3"在吗?不在那就让它留下来。
这里实际上使用到了分布式条件类型的特性,假设 Exclude 接收 T U 两个类型参数,T 联合类型中的类型会依次与 U 类型进行判断,如果这个类型参数在 U 中,就剔除掉它(赋值为 never)
type Exclude
type Omit
type Extract
type MyNav = "a" | "b" | "b";interface INavWidgets { widgets: string[]; title?: string; keepAlive?: boolean;}const router: Record
// K extends keyof any 约束K必须为联合类型type Record
type ReturnType
type Parameters
type ConstructorParameters< T extendsnew (...args: any) => any> = T extendsnew (...args: infer P) => any ? P : never;加上new关键字来使其成为可实例化类型声明,也就是此处的泛型约束需要一个类。
这个是获得类的构造函数入参类型,如果把待 infer 的类型放到其返回处,想想 new 一个类的返回值是什么?实例!所以我们得到了实例类型InstanceType:
type InstanceType
模板类型相关
TypeScript 4.1[7] 中引入了模板字面量类型,使得可以使用${} 这一语法来构造字面量类型,如:
type World = 'world';// "hello world"type Greeting = hello ${World}
;随之而来的还有四个新的工具类型:
type Uppercase = intrinsic;type Lowercase = intrinsic;type Capitalize = intrinsic;type Uncapitalize = intrinsic;它们的作用就是字面意思,不做解释了。相关的PR见 40336[8],作者Anders Hejlsberg是C#与Delphi的首席架构师,同时也是TS的作者之一。
intrinsic代表了这些工具类型是由TS编译器内部实现的,其实也很好理解,我们无法通过类型编程来改变字面量的值,但我想按照这个趋势,TS类型编程以后会支持调用Lodash方法也说不定。
社区工具类型
这一部分的工具类型大多来自于utility-types[9],其作者同时还有react-redux-typescript-guide[10] 和 typesafe-actions[11]这两个优秀作品。同时,也推荐type-fest[12]这个库,和上面相比更加接地气一些。其作者的作品...,我保证你直接或间接的使用过(如果不信,一定要去看看...我刚看到的时候是真的震惊的不行)。
我们由浅入深,先封装基础的类型别名和对应的类型守卫:
exporttype Primitive = | string | number | bigint | boolean | symbol | null | undefined;exportconst isPrimitive = (val: unknown): val is Primitive => {if (val === null || val === undefined) {returntrue; }const typeDef = typeof val;const primitiveNonNullishTypes = ["string","number","bigint","boolean","symbol", ];return primitiveNonNullishTypes.indexOf(typeDef) !== -1;};exporttype Nullish = null | undefined;exporttype NonUndefined = A extendsundefined ? never : A;// 实际上TS也内置了type NonNullable
趁着对 infer 的记忆来热乎,我们再来看一个常用的场景,提取 Promise 的实际类型:
const foo = (): Promise
exporttype PromiseType
递归的工具类型
前面我们写了个PartialReadonlyRequired等几个对接口字段进行修饰的工具类型,但实际上都有局限性,如果接口中存在着嵌套呢?
type Partial
如果不是对象类型,就只是加上?修饰符如果是对象类型,那就遍历这个对象内部重复上述流程。是否是对象类型的判断我们见过很多次了, T extends object即可,那么如何遍历对象内部?实际上就是递归。
exporttype DeepPartial
那么DeepReadobly、 DeepRequired也就很简单了:
exporttype DeepMutable
另外一种省心的方式是不进行条件类型的判断,直接全量递归所有属性~
返回键名的工具类型
在有些场景下我们需要一个工具类型,它返回接口字段键名组成的联合类型,然后用这个联合类型进行进一步操作(比如给 Pick 或者 Omit 这种使用),一般键名会符合特定条件,比如:
可选/必选/只读/非只读的字段(非)对象/(非)函数/类型的字段来看个最简单的函数类型字段FunctionTypeKeys:
exporttype FunctTypeKeys
interface IWithFuncKeys { a: string; b: number; c: boolean; d: () =>void;}type WTFIsThis
type UseIt1 = { a: never; b: never; c: never; d: "d";};UseIt会保留所有字段,满足条件的字段其键值为字面量类型(值为键名)
加上后面一部分:
// "d"type UseIt2 = UseIt1[keyof UseIt1];这个过程类似排列组合:never类型的值不会出现在联合类型中
// string | numbertype WithNever = string | never | number;
所以{ [K in keyof T]: ... }[keyof T]这个写法实际上就是为了返回键名(准备的说是键名组成的联合类型)。
那么非函数类型字段也很简单了,这里就不做展示了,下面来看可选字段OptionalKeys与必选字段RequiredKeys,先来看个小例子:
type WTFAMI1 = {} extends { prop: number } ? "Y" : "N";type WTFAMI2 = {} extends { prop?: number } ? "Y" : "N";如果能绕过来,很容易就能得出来答案。如果一时没绕过去,也很简单,对于前面一个情况,prop是必须的,因此空对象{}并不能满足extends { prop: number },而对于prop为可选的情况下则可以。因此我们使用这种思路来得到可选/必选的键名。
{} extends Pick
exporttype OptionalKeys
interface MutableKeys { readonlyKeys: never; notReadonlyKeys: "notReadonlyKeys";}然后再获得不为never的字段名即可。
这里还是要表达一下对作者的敬佩,属实巧妙啊,首先定义一个工具类型IfEqual,比较两个类型是否相同,甚至可以比较修饰前后的情况下,也就是这里只读与非只读的情况。
type Equal
exporttype MutableKeys
泛型 Q 在这里不会实际使用,只是映射类型的字段占位。X Y 同样存在着 分布式条件类型, 来依次比对字段去除 readonly 前后。同样的有:
exporttype IMmutableKeys
前面我们实现的 Pick 与 Omit 是基于键名的,假设现在我们需要按照值类型来做选取剔除呢?
其实很简单,就是T[K] extends ValueType即可:
exporttype PickByValueType
工具类型一览
总结下我们上面书写的工具类型:
全量修饰接口:PartialReadonly(Immutable)MutableRequired,以及对应的递归版本。裁剪接口:PickOmitPickByValueTypeOmitByValueType基于 infer:ReturnTypeParamTypePromiseType获取指定条件字段:FunctionKeysOptionalKeysRequiredKeys ...需要注意的是,有时候单个工具类型并不能满足你的要求,你可能需要多个工具类型协作,比如用FunctionKeys+Pick得到一个接口中类型为函数的字段。
如果你之前没有关注过 TS 类型编程,那么可能需要一定时间来适应思路的转变。我的建议是,从今天开始,从现在的项目开始,从类型守卫、泛型、最基本的Partial开始,让你的代码精准而优雅。
尾声
在结尾说点我个人的理解吧,我认为 TypeScript 项目实际上是需要经过组织的,而不是这一个接口那一个接口,这里一个字段那里一个类型别名,更别说明明可以使用几个工具类型轻松得到的结果却自己重新写了一遍接口。但很遗憾,要做到这一点实际上会耗费大量精力,并且对业务带来的实质提升是微乎其微的(长期业务倒是还好),毕竟页面不会因为你的类型声明严谨环环相扣就 PVUV 暴增。我目前的阶段依然停留在寻求开发的效率和质量间寻求平衡,目前的结论:多写 TS,脚本/爬虫/配置/demo,能用TS的就用TS写,写到如臂使指,你的效率就会 upu
参考资料
[1]ts-node: https://github.com/TypeStrong/ts-node
[2]#1565: https://github.com/remy/nodemon/issues/1565#issuecomment-490429334
[3]走近 MidwayJS:初识 TS 装饰器与 IoC 机制: https://juejin.im/post/6859314697204662279
[4]TypeScript3.7: https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/
[5]top-10-javascript-errors: https://rollbar.com/blog/top-10-javascript-errors/
[6]尤大的知乎回答: https://www.zhihu.com/search?type=content&q=ts%20never
[7]TypeScript 4.1: https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/
[8]40336: https://github.com/microsoft/TypeScript/pull/40336
[9]utility-types: https://github.com/piotrwitek/utility-types
[10]react-redux-typescript-guide: https://github.com/piotrwitek/react-redux-typescript-guide
[11]typesafe-actions: https://github.com/piotrwitek/typesafe-actions
[12]type-fest: https://github.com/sindresorhus/type-fest
The End
欢迎自荐投稿到《前端技术江湖》,如果你觉得这篇内容对你挺有启发,记得点个 「在看」哦
举报/反馈
引用: https://baijiahao.baidu.com/s?id=1705957833047970461&wfr=spider&for=pc