🛑 类型编程
1. 索引签名(映射)类型
1 | type User = { |
前面的代码中,我们可以通过修饰符?
限定有哪些属性值,但是最多也就是name,age和sex这三个属性,无非也就是age和sex这两个属性写与不写的问题了。
如果希望在Typescript中也能动态的添加属性,还是不行,这个时候我们可以借助索引签名类型(Index Signatures)
1 | type User = { |
[key:T]:U
这种写法称为索引签名,相当于通过这种简单的方式告诉Typescript,指定的对象可能有更多的键。基本的意思是:“在这个对象中,类型为T的键,对应的值为U类型”
在这个例子中我们声明的键的类型为 string([key: string]
),这也意味着在实现这个类型结构的变量中只能声明字符串类型的键
但由于 JavaScript 中,对于 user[prop]
形式的访问会将数字索引访问转换为字符串索引访问,也就是说, user[123]
和 user['123']
的效果是一致的。因此,在字符串索引签名类型中我们仍然可以声明数字类型的键。类似的,symbol 类型也是如此:
1 | const user: User = { |
索引签名类型也可以和具体的键值对类型声明并存,但是需要注意,具体的键值类型也需要符合索引签名类型的声明:
1 | type User = { |
如果希望这里的age不报错,上面的索引签名类型可以使用联合类型
1 | type User = { |
索引签名类型最常见场景是在重构 JavaScript 代码的时候或者创建类型声明的时候,为内部属性较多的对象声明一个 any 的索引签名类型,以此来暂时支持对类型未明确属性的访问
1 | type AnyTypeHere = { |
而且,之前我们必须声明属性明确的对象字面量类型,这对于有些时候声明一个空的对象就不太友好,但是又不能直接声明对象为obj,那么这里的索引签名类型就非常合适这个场景了。
1 | type AnyTypeHere = { |
其实,Typescript也专门提供了一个类似的工具类型Record,方便这种情况我们的使用
2. keyof
keyof
操作符。它可以将对象中的所有键转换为对应字面量类型,然后再组合成联合类型。
1 | type User = { |
在 VS Code 中悬浮鼠标只能看到 keyof User
,看不到其中的实际值,你可以这么做
1 | type UserKeys = keyof User & {} // "id" | "name" | "age" |
甚至我们可以结合这typeof
,直接从一个对象上,获取这个对象键的所有联合类型
1 | const user = { |
也可以和方括号运算符结合
1 | type Person = { |
结合着泛型,方括号运算符以及extends受限的泛型,可以直接重写之前我们在重载中写过的代码:
1 | type TagName = keyof HTMLElementTagNameMap; |
3. in
运算符遍历
前面讲了in运算符在Typescript可以用来检查属性,在控制流中实现对类型的守卫。
除了类型守卫的作用,in
运算符还能遍历联合类型的每一个成员类型
1 | type U = 'a'|'b'|'c'; |
上面的讲解keyof
的时候,不是用到了这样的写法
1 | type A = keyof Person; |
那完全可以把keyof
放入到索引中使用
1 | type User = { |
现在固定了keyof User
,那么我们可以使用泛型,增加一般性
1 | type Copy<T> = { |
注意:
keyof T
这两个的结合得到的是一个联合类型string | number | symbol
,因为T
是泛型,并不知道T
类型的每个键到底是什么类型,可以看一下keyof any
的结果当然
[key in keyof T]
现在这样写没有什么问题,但是如果后面要和as,和模板字符串类型连用的话,要注意类型的转换,最好直接让键就是string
类型
[key in keyof T & string]
4. 类型编程的理解
4.1 属性修饰符
我们类型编程的代码已经逐步过渡成对泛型的处理:
1 | type User = { |
上面的Copy<T>
类型只需要我们稍稍做修改,就能成为一个很有用的新的类型别名
1 | type MyReadonly<T> = { |
就在之前的代码签名加了readonly
,这个类型别名就能实现将你传递的类型T所有的属性变为readonly
1 | type User = { |
又或者说,直接在后面加上?
,就能将原来类型中所有的属性变为可选
1 | type MyPartial<T> = { |
MyReadonly
是Readonly<Type>
的具体实现
MyPartial
是Partial<Type>
的具体实现
其实,这种在现有类型的基础上创建新的类型的方式,在TS中也有专门的称呼:映射类型(Mapped Types),其实和索引签名类型很类似,差别只是索引签名用于定义对象可以有哪些类型的键和值,适用于属性名未知或动态的情况。映射类型则允许你在现有类型的基础上创建新的类型,通过对原始类型的属性进行转换或应用修饰符,来满足更具体的类型设计需求
4.2 修饰操作符+
,-
其实上面的readonly
与?
的写法是简写,具体应该是给原来的类型加上readonly
,给原来的类型加上?
+
修饰符:写成+?
或+readonly
,为映射属性添加?
修饰符或readonly
修饰符。
1 | type MyReadonly<T> = { |
既然有+
,那就有-
–
修饰符:写成-?
或-readonly
,为映射属性移除?
修饰符或readonly
修饰符。
1 | type MyRequired<T> = { |
4.3 泛型编程的理解
Javascript的编程大家很熟悉,如果我们想处理一个值,然后返回一个新的值,理所应当的想到的就是函数
1 | function myPartial(type){ |
上面的伪代码使用函数无非就两步:
1、声明函数,传入参数
2、调用函数,获取到新的返回值
如果我们操作类型,也能像Javascript的函数处理一样,操作旧类型,然后得到了新的类型,那就很方便了。
1 | // 可以当做是函数,可以接受任意类型。 |
来看一下有多像:
声明的时候
调用的时候
最后只需要将声明中{todos...}
相关语法,换成Typescript的语法就行了
1 | type MyPartial<T> = { |
有了这个理论,我们来看之前的映射类型,我们写了这样的代码:
1 | type AnyTypeHere = { |
这样写肯定是有一定缺陷的,固定了键的类型,而且值的类型是any,当然我们介绍了Record工具的用法,可以通过Record工具,帮我们定义需要的泛型。这个简单工具,我们完全也可以自己实现。
1 | // 对于js来说,我们对值操作 |
1 | // 对于TS来说,我们对类型操作 |
5. 关联泛型
如果现在希望实现这么一个效果,在原有对象类型的属性上进行挑选,根据挑选属性的结果,形成新的类型
1 | type User = { |
1 | type MyPick<T, K extends keyof T> = { |
关键点在于:
1、确定需要的泛型参数个数
2、第二个泛型参数的类型应该来源于第一个参数
6. 方括号运算符常见操作
6.1 获取值的类型
1 | type User = { |
数组一样可以处理
1 | const arr = ["admin", "user", "client"]; |
将上面的数组通过as const
转为只读元组类型之后,得到的是具体字面量类型的联合
1 | const arr = ["admin", "user", "client"] as const; |
当然,我们也能写成泛型工具
1 | type ArrType<T extends readonly any[]> = T[number]; |
6.2 获取数组的长度
可以通过['length']
获取元组类型的具体长度number字面量类型,注意如果仅仅是数组,只能获取number类型
1 | const arr = ["admin", "user", "client"] as const; |
同样也能写成泛型工具:
1 | type ArrLen<T extends readonly any[]> = T['length']; |
6.3 结合泛型使用扩展运算符
比如现在希望写一个泛型工具,实现两个元组类型的拼接
1 | type Result = Concat<[1,2],[3,4]>; //[1,2,3,4] |
咋一看没啥思路,但是其实ts和js一样,支持... Spread扩展运算符
1 | type Concat<T extends any[], U extends any[]> = [...T, ...U]; |
7. 条件类型与类型兼容性
条件类型是ts中非常强大的功能,看起来有点像 JavaScript 中的条件表达式(条件 ? true 表达式 : false 表达式
):
SomeType extends OtherType ? TrueType : FalseType
当
extends
左边的类型可以赋值给右边的类型时(extends
左边的类型与右边兼容时),你将获得第一个分支(“true” 分支)中的类型;否则你将获得后一个分支(“false” 分支)中的类型。
不过首先要解惑的是,为什么使用extends
?,而不是===
或者其他运算符。
这是因为在类型层面中,对于能够进行赋值操作的两个变量,我们并不需要它们的类型完全相等,只需要具有兼容性,而两个完全相同的类型,其 extends 自然也是成立的。
1 | type T = 1 extends number ? true : false; // true |
在实际操作中,我们经常会使用条件类型来判断一个类型和另一个类型是否兼容
1 | type T1 = 1 extends number ? true : false; // true |
大家可以下去自己慢慢测试类型兼容性。
但是,下面的代码会让你产生困惑:
1 | type T9 = {} extends object ? true : false; // true |
这三个建议大家不需要细究,知道他们有这个问题:你中有我,我中有你。这是**TS“系统设定”**的问题。
记住给大家的这个图:
原始类型 < 原始类型对应的装箱类型 < Object 类型
其实还有更神奇的:
1 | type T15 = string extends any ? true : false; // true |
是不是很神奇?实际上,还是因为TS“系统设定”的原因,因为any其实从系统底层的意义来说,就是为了保证和js的兼容性存在的。大家不需要纠结。记住any/unknown是所有类型的顶层类型就行
别忘记,never
类型是所有类型的子类型
1 | type T22 = never extends "Hello" ? true : false; // true |
8. 条件类型与泛型
条件类型当然可以和泛型结合,然后组合出很多类型编程相关的处理。
我们可以定义一个泛型类型IsString
,根据T
的类型,判断返回的具体类型是true
还是false
:
1 | type IsString<T> = T extends string ? true : false; |
再来有下面的题目:
实现一个 IF
类型,它接收一个条件类型 C
,一个判断为真时的返回类型 T
,判断为假时的返回类型 F
。 C
只能是 true
或者 false
, T
和 F
可以是任意类型。
1 | type A = If<true, 'a', 'b'>; // 'a' |
这就非常的简单了:
1 | type If<C extends boolean, T, F> = C extends true ? T : F; |
若位于
extends
右侧的类型包含位于extends
左侧的类型(即狭窄类型 extends 宽泛类型)时,结果为 true,反之为 false。这对于基础类型和字面量类型来说大家很容易分辨。如果是对象呢?
当
extends
作用于对象时,若在对象中指定的 key 越多,则其类型定义的范围越狭窄,对象字面量的兼容性问题是我们一直提及的,希望大家注意。
上面这句话,其实我们之前在受限的泛型中已经感受过了
1 | type ObjLength = { |
函数中传入的泛型T只要拥有length: number
属性,就兼容。在条件类型中同样适用
1 | type Result = { a: string, b: boolean } extends { a: string } ? true : false // true |
extends
左边的对象字面量类型{ a: string, b: boolean }
拥有两个属性,右边的对象字面量类型{ a: string }
只有一个属性,左边有更多的属性,并且和右边有一样的属性{ a: string }
。那么我们就可以说对象字面量类型{ a: string, b: boolean }
和{ a: string }
类型兼容,因此上面的Result
的类型为true
上面的代码中不是写过这样的代码吗?
1 | type Message<T extends { message: unknown }> = T['message'] |
如果没有message类型现在这里的代码typescript会提示报错,我们也能通过判断让其获取其他类型
1 | type Message<T> = T extends { message: unknown } ? T['message'] : never |
比如还能根据方括号运算符的特点,直接提取数组的类型
1 | type Flatten<T> = T extends any[] ? T[number] : T; |
来写一个现在看起来稍微离谱的写法:
1 | type GetType<T> = T extends string ? "string" |
再来上点难度:实现泛型工具Merge
将两个类型合并成一个类型,第二个类型的键会覆盖第一个类型的键。
1 | type foo = { |
1 | type Merge<F, S> = { |
9. 分布(分发)式条件特性
条件类型在结合联合类型+泛型使用时,会触发分布式条件特性
分布式条件类型 | 等价于 |
---|---|
string extends T ? A : B |
string extends T ? A : B |
(string | number) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) |
(string | number | boolean) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) | (boolean extends T ? A : B) |
还记着之前写过这样的类型工具吗
1 | type IsString<T> = T extends string ? true : false; |
如果我们传入的T
是一个联合类型,那么就会触发分布式特性
1 | type IsString<T> = T extends string ? 1 : 2; |
我们可以写的再灵活一些。比如我们定义下面的类型:
1 | type MyInclude<T, U> = T extends U ? T : never; |
我们可以这样使用:
1 | type A = "a" | "b" | "c"; |
其实MyInclude
干了类似于下面的事情:
1 | type C = MyInclude<"a", "a" | "b"> |
我们可以替换为具体的定义来理解一下:
1 | type C = ("a" extends "a" | "b" ? "a" : never) |
这样其实得到结果:
1 | type C = "a" | "b" | never |
最后根据never的特性,直接省略掉,得到最后的结果
1 | type C = "a" | "b" |
上面MyInclude
这个代码例子其实完全可以反过来,又形成另外一个类型:
1 | type MyExclude<T, U> = T extends U ? never : T; |
大家可以按照上面的步骤,自行分析一下
MyInclude
实际上是Extract<Type, Union>工具类型的实现
MyExclude
实际上是Exclude<UnionType, ExcludedMembers>工具类型的实现
根据Exclude<UnionType, ExcludedMembers>和Pick<Type, Keys>工具还能实现和Pick<Type, Keys>工具相反的效果
1 | type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>> |
1 | type Foo = { |
MyOmit的实现,其实就是Omit<Type, Keys>工具类型的实现
这几个工具,我们可以做个案例来练习一下,比如有如下对象字面量类型:
1 | type User = { |
现在希望实现一个工具类型,将选择键名设置为可选,比如,如果设置age,tel和address,那么经过工具类型转换之后,上面的类型别名就会变为:
1 | type User = { |
1 | // type RequiredPick = Omit<User, "age"|"tel"|"address"> |
最后,触发分布式条件类型需要注意两点:
1、类型参数需要通过泛型参数的方式传入,也就是下面这种直接写死的是不行的
1 | // 始终都是"no" |
2、类型参数需要是一个联合类型,并且条件中的泛型参数不能被包裹,比较下面两个结果的区别
1 | type B<T> = T extends any ? T[] : never; |
10. 映射类型的属性过滤
上面我们通过Pick
+ Exclude
实现了Omit
类型工具,那我们能不能完全自己实现,不借助已有的类型工具呢?也可以,不过我们需要掌握一个技巧:通过as + never
实现属性过滤的效果
1 | type User = { |
在例子中,映射 K in keyof T
获取类型 T 的每一个属性以后,后面紧跟着as
其实是可以为键重新映射命名的
不过现在,它的键名重映射 as P extends K ? never : P
,使用了条件运算符,又会触发分布式处理
“id” —> “tel” | “address” ? never : “id”
“name” —> “tel” | “address” ? never : “name”
“tel” —> “tel” | “address” ? never : “tel”
“address” —> “tel” | “address” ? never : “address”
“id” | “name” | never | never —> “id” | “name”
我们还能再升级一下,比如:只保留User值类型是string类型的,生成新的类型
1 | type PickStringValueType<T> = { |
当然,你想反过来,去掉值类型是string类型的,将K
和never
换个位置就行了
其实上面做的更加普遍性一些,就完全可以写成一个类型工具:
1 | type PickByType<T, U> = { |
11. infer
通过使用infer
关键字,还可以在条件类型中声明泛型类型。
1 | // type Flatten<T> = T extends any[] ? T[number] : T; |
对比之前方括号运算符T[number]
其实使用infer
关键字之后,我们的类型代码更易读了。如果你不是对方括号运算符那么的熟悉,T[number]
的写法本身就很具有迷惑性。
infer
,意为推断,如 infer U
中 U
就表示 待推断的类型,你完全可以先把这里的infer U
看做any
,当执行时,typescript推导出具体的类型,并将类型赋值给U
比如,我们希望获取数组第一个元素的类型:
1 | type arr1 = ['a', 'b', 'c']; |
我们可以通过infer进行推断,把第一个元素和其他元素分开,再连成一个数组就好。
1 | type First<T extends any[]> = T extends [infer F, ...infer R] ? F : never; |
当然,其实也可以用T[K]
,使用方括号运算符
1 | type First<T extends any[]> = T extends [] ? never : T[0]; |
T[0]
其实就是获取第0个位置上元素的类型,这里判断T
和一个空元组的兼容性,也就是T不是一个空元组,那就得到第0个位置上元素的类型。
其实还能有下面的写法:
1 | type First<T extends any[]> = T['length'] extends 0 ? never : T[0]; |
T['length']
可以获取length属性的类型,其实也就是数组长度,不是0的话,得到第0个位置上元素的类型
1 | type ArrayLength<T extends any[]> = T['length']; |
继续,交换元组两个位置上的类型
1 | type Swap<T extends any[]> = T extends [infer A, infer B] ? [B, A] : T; |
当然,如果你希望无论如何数组的首位都进行交换,一样简单,加上**...
操作符**即可
1 | type Swap<T extends any[]> = T extends [infer A, ...infer Rest ,infer B] ? [B,...Rest, A] : T; |
同样,函数也能进行推断
1 | type GetReturnType<T> = T extends (...args: any[]) => infer R |
GetReturnType
实际上是ReturnType<Type>
的具体实现
12. 模板字符串类型
TS 字符串模板类型的写法跟 JS 模板字符串非常类似
1 | type World = 'world'; |
除了前面的 type
跟 JS
不一样之外,后面就是一模一样了,通过 ${}
包裹,里面可以直接传入类型变量,使用变量的模板字符串可以实现你意想不到的效果。
1 | type Direction = "left" | "right" | "top" | "bottom"; |
使用模板字符串,联合类型会被挨个组合到模板中,最后轻松的生成一个包含各种组合的联合类型
使用对象也能处理一些更多的内容:
1 | const person = { |
加入映射类型:
1 | type A = { |
但是如果想做的通用一点,也就是和泛型结合,会遇到问题:
1 | // 结合泛型使用,由于keyof T得到的是一个联合类型,不能直接用于模板字符串拼接 |
Typescript官方也提供了很多内置的字符串工具Intrinsic String Manipulation Types,根据名字大概也能猜测出意思
1 | type World = 'world'; |
这还仅仅是字符串模板的初级使用,结合这泛型编程,可以玩出很多花样
比如提供一个对象字面量类型,通过字符串模板直接得到Getter和Setter类型
1 | type User = { name: string; age: number; address: string }; |
还可以处理的更通用一些:
1 | type ObjectWithGetterSetter<T extends object> = T & AddGetter<T> & AddSetter<T>; |
13. 递归复用
现在有这么一个需求,需要将字符串字面量类型中的每个值类型取出,组成联合类型,类型于:
1 | type A = "12345" |
如果字符串字符串长度不变,那我们可以直接使用infer
进行类型推断
1 | type A = "12345" |
但是这仅仅才5个字符串,如果字符串较多的话,不是要infer
推断一堆类型,比如来个九字真言,难道要infer9
次?
1 | type A = "临兵斗者皆阵列前行" |
这个时候我们就可以使用递归复用:当处理数量较多的类型的时候,可以只处理一个类型,然后递归的调用自身处理下一个类型,直到结束条件
1 | type NineMantra = "临兵斗者皆阵列前行" |
和字符串字面量类型很类似的,如果一个数组要做一些类似的类型处理,那一样可以递归,比如,我们要把数组中的元素类型倒序
1 | type ReverseArr<T extends any[]> = |
同样,我们使用递归复用:
1 | type ReverseArr<T extends any[]> = |
再来一个,比如,我们现在通过编写一个类型工具,获取一个字符串字面量类型的长度
1 | type S = LengthOfString<"12345">; // 5 |
我们可以思考,之前我们讲过数组类型是不是可以获取长度,通过T[‘length’],那我们能不能把字符串类型转成数组类型呢?完全可以,通过infer推断和递归复用:
1 | type LengthOfString< |
通过递归复用,还能实现对索引映射类型的深递归,比如。我们希望将一个层级较深的对象类型全部属性转为readonly
只读
1 | type User = { |
如果我们使用之前写的MyReadonly处理,仅仅只会把第一个层级的属性转变为readonly
1 | type MyReadonly<T> = { |
这里我们简单使用递归就能实现想要的效果
1 | type DeepReadonly<T extends Record<string, any>> = |
不过这样不好看到最后转换的效果,因为TS为了保证性能,并不会做深层的计算。
有一个比较实用的类型体操技能,就是在比较复杂的,特别是需要递归计算的类型体操计算外,包裹一层代码:
1 | T extends any ? |
这样我们就可以看到最后计算完成的效果,比如把上面的代码换成:
1 | type DeepReadonly<T extends Record<string, any>> = |
14. 分发逆变推断
根据函数的型变,可以做出一些比较复杂的类型体操变化
实现高级工具类型函数:联合类型转为交叉类型
1 | type I = UnionToIntersection<{ id: 1 } | { name: "jack" } | { sex: "男" }>; |
在所有类型转换中,联合转交叉可以说是比较有难度的了
核心在于其他类型都有比较简单的遍历方法,比如元组的 T extends [infer F, ...infer R]
,对象的 [P in keyof T]: T[P]
,还有字符串的遍历套路,在这些类型中,转交叉其实非常简单。这里以元组为例:
1 | type TupleToIntersection<T extends any[]> = |
但是对联合类型就麻烦了,因为我们无法把联合类型一个一个拉出来进行遍历,联合类型只有分布式(分发)特性。但是分发特性也是从一个联合类型返回一个新的联合类型,并不能转成交叉类型。
那么这个题,可以通过利用联合类型的分布式特性
+ 逆变特性
+ infer类型推断
实现这个效果
1 | type UnionToIntersection<U> = |
函数参数逆变的特性不知道大家有没有忘记:
1 | type C = { id: 1, name: "jack", sex: "男" } extends { id: 1 } ? 1 : 2; // 1 |