💁🏻♂️TS 类型体操快速入门,简单题套路总结
前言
学习类型体操要求对于 TS 的基本语法有了解,最好是有实际使用过,不然还是比较难理解的,也很难在解题的过程中学习如何将这些技巧反哺到实际项目的优化中。
GitHub 仓库:github.com/type-challe…
实现一个 Pick 函数
从类型 T
中选择出属性 K
,构造成一个新的类型。
type MyPick<T, K extends keyof T> = {
[P in K]:T[P]
}
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<Expected1, MyPick<Todo, 'title'>>>,
Expect<Equal<Expected2, MyPick<Todo, 'title' | 'completed'>>>,
// @ts-expect-error
MyPick<Todo, 'title' | 'completed' | 'invalid'>,
]
interface Todo {
title: string
description: string
completed: boolean
}
interface Expected1 {
title: string
}
interface Expected2 {
title: string
completed: boolean
}
K extends keyof T
这一步操作是指定泛型 K 的类型必须为 T 的 key 值的联合类型,例如泛型 T 传入了 {a:string;b:string}
。那么 K 就为 a|b
。
P in K
这一步是用于判断,in 是 js 和 ts 中都有的一个运算符,在 js 中用于判断一个对象存不存在某个属性,使用方式如下:
propertyName in objectVariable
这道题的重点就在于我们需要遍历第二个泛型 K 这个联合类型,遍历的方式是用 in
,遍历后我们需要返回一个新的对象类型,对象需要是由键值对组成的,而我们已经能拿到键了,那么通过索引访问 T[P]
的方式再拿到值,组合成一个对象即可。
实现 Readonly
该 Readonly
会接收一个 泛型参数,并返回一个完全一样的类型,只是所有属性都会被 readonly
所修饰。
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<MyReadonly<Todo1>, Readonly<Todo1>>>,
]
interface Todo1 {
title: string
description: string
completed: boolean
meta: {
author: string
}
}
这一题与上一题的实现思路类似,传入一个对象类型,将对象中的所有属性修改为只读属性,那么重点在于遍历对象属性,依然是使用 in
去遍历,但是这里我们没有一个现成的联合类型用于遍历,所以我们需要用 keyof
这个语法,它可以将对象中的所有键取出,返回一个联合类型。
通过 in
遍历 keyof
转换的联合类型,再为每个键加上 readonly
的修饰符,值通过索引访问 T[P]
的方式获得,再组合成一个新的对象类型即可。
将元组转换为对象
传入一个元组类型,将这个元组类型转换为对象类型,这个对象类型的键/值都是从元组中遍历出来。
type TupleToObject<T extends readonly any[]> = {
[K in T[number]]: K
}
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
const tupleNumber = [1, 2, 3, 4] as const
const tupleMix = [1, '2', 3, '4'] as const
type cases = [
Expect<Equal<TupleToObject<typeof tuple>, { tesla: 'tesla'; 'model 3': 'model 3'; 'model X': 'model X'; 'model Y': 'model Y' }>>,
Expect<Equal<TupleToObject<typeof tupleNumber>, { 1: 1; 2: 2; 3: 3; 4: 4 }>>,
Expect<Equal<TupleToObject<typeof tupleMix>, { 1: 1; '2': '2'; 3: 3; '4': '4' }>>,
]
// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>
这里泛型 T
通过 extends
限制为了一个只读的任意类型数组,我们需要将数组转为对象,根据测试用例我们可以看到将数组中的每一项转换为一个键与值相同的对象属性,所以我们要遍历数组,但in 只能用于遍历联合类型,因此我们要用 T[number]
的方式将数组转换成由数组中每一项组成的联合类型,再通过 in 去遍历这个联合类型,最后将索引 K 与值 K 一一对应组合成对象即可。
第一个元素的类型
实现一个通用First<T>
,它接受一个数组T
并返回它的第一个元素的类型。
type First<T extends any[]> = T extends [infer F, ...infer rest] ? F : never;
// or
type First<T extends any[]> = T[0] extends undefined ? never: T[0];
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<First<[3, 2, 1]>, 3>>,
Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
Expect<Equal<First<[]>, never>>,
Expect<Equal<First<[undefined]>, undefined>>,
]
type errors = [
// @ts-expect-error
First<'notArray'>,
// @ts-expect-error
First<{ 0: 'arrayLike' }>,
]
这一题的解法有两种,第一种是通过 infer
来进行推导,第二种则是直接使用索引访问获取第一个元素的类型,infer 的方式这里先不展开,先讲讲两种方式都需要使用的 A extends B ? X : Y
这种条件类型关键字,它的语法类似 js 中的三目表达式,只不过前面的判断变成了 A 类型是否为 B
类型的子集,例如类型 “A”|”B”
就是类型 “A”|”B”|“C”
的子集。
而 T[0] extends undefined
也就是判断 T[0]
是否为 undefined
的子集,这一步的作用是判断传入的类型是否为数组,如果为数组就返回数组第一项的类型,否则返回 never
。
获取元组长度
创建一个通用的Length
,接受一个readonly
的数组,返回这个数组的长度。
type Length<T> = T["length"]
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
const tesla = ['tesla', 'model 3', 'model X', 'model Y'] as const
const spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] as const
type cases = [
Expect<Equal<Length<typeof tesla>, 4>>,
Expect<Equal<Length<typeof spaceX>, 5>>,
// @ts-expect-error
Length<5>,
// @ts-expect-error
Length<'hello world'>,
]
ts 中元祖类型和 js 中的数组一样具有 length
属性,所以这一题直接使用索引访问即可获取元祖的长度即可。
实现内置的Exclude类型
从联合类型T中排除U的类型成员,来构造一个新的类型。
type MyExclude<T, U> = T extends U ? never : T
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<MyExclude<'a' | 'b' | 'c', 'a'>, 'b' | 'c'>>,
Expect<Equal<MyExclude<'a' | 'b' | 'c', 'a' | 'b'>, 'c'>>,
Expect<Equal<MyExclude<string | number | (() => void), Function>, string | number>>,
]
这里你可能会好奇前面不是说 TS 中的条件判断和 JS 中的三目运算符差不多嘛,那你的实现方法是 T 如果不是 U 的子集返回 never,否则返回 T 那为什么这里返回的却是联合类型呢?
其实 extends 的机制是这样子的,如果 extends 前面的参数是一个泛型类型,当传入该参数的是联合类型,则使用分配律计算最终的结果,下面上一个简单的例子:
type MyExclude<T, U> = T extends U ? never : T;
type A = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b'|'c'
// 相当于
type A =
| ('a' extends 'a' ? never : 'a')
| ('b' extends 'a' ? never : 'b')
| ('c' extends 'a' ? never : 'c');
想象这个例子应该能直观的看出最终 A 的类型是如何得到的,如果我们两个泛型都传入联合类型,则会如下例子:
ype MyExclude<T, U> = T extends U ? never : T;
type A = MyExclude<'a' | 'b', 'c' | 'd'>; // 'a'|'b'
// 相当于
type A =
| ('a' extends 'c' ? never : 'a')
| ('a' extends 'd' ? never : 'a')
| ('b' extends 'c' ? never : 'b')
| ('b' extends 'd' ? never : 'b');
记住联合类型的泛型使用 extends 进行条件判断这个前置条件,然后再根据分配原则进行分析就能看懂啦。
关于条件类型的更多用法可以参考这个文档:www.typescriptlang.org/zh/docs/han…
获取 Promise 泛型的类型
假如我们有一个 Promise 对象,这个 Promise 对象会返回一个类型。在 TS 中,我们用 Promise 中的 T 来描述这个 Promise 返回的类型。请你实现一个类型,可以获取这个类型。
import type { Equal, Expect } from '@type-challenges/utils'
type MyAwaited<T> = T extends Promise<infer R> ? MyAwaited<R> : T;
type X = Promise<string>
type Y = Promise<{ field: number }>
type Z = Promise<Promise<string | number>>
type Z1 = Promise<Promise<Promise<string | boolean>>>
type cases = [
Expect<Equal<MyAwaited<X>, string>>,
Expect<Equal<MyAwaited<Y>, { field: number }>>,
Expect<Equal<MyAwaited<Z>, string | number>>,
Expect<Equal<MyAwaited<Z1>, string | boolean>>,
]
// @ts-expect-error
type error = MyAwaited<number>
这一题我们可以看到前面使用过的 infer
关键字,infer 是用于推导类型变量的,它需要配合 extends
条件判断进行使用。在进行条件判断时,我们可以将判断的条件中设置一个待推断的变量。下面放个例子:
type ParamType<T> = T extends (arg: infer P) => any ? P : T;
type Func = (user: User) => void;
type Param = ParamType<Func>; // Param = User
这里我们将一个方法类型的参数设置为 P 这个变量,在泛型 T 满足为一个方法时,就将这个方法的参数类型 P 给返回,否则返回 T 类型本身。
再看回我们的题目,我们将 Promise 的泛型设置为了 R 这个变量,当传入的类型满足为一个 Promise 类型时,就返回推导出的 R
,但是在题目中我们看到并没有直接返回 R,而是再次调用了MyAwaited,这是为了防止 Promise 的泛型也是 Promise 类型的情况,像测试用例中的 Z 和 Z1,而这种使用方式也就是递归调用,不断去获取 Promise 的泛型直到最终传入的泛型 T 不满足条件再将 T 返回。
这里需要注意,我们推导的这个变量只能在满足条件的那个分支中使用,也就是?
后 :
前的那部分。
实现 concat
在类型系统里实现 JavaScript 内置的 Array.concat
方法,这个类型接受两个参数,返回的新数组类型应该按照输入参数从左到右的顺序合并为一个新的数组。
type Concat<T extends any[] ,U extends any[]> = [...T, ...U];
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<Concat<[], []>, []>>,
Expect<Equal<Concat<[], [1]>, [1]>>,
Expect<Equal<Concat<[1, 2], [3, 4]>, [1, 2, 3, 4]>>,
Expect<Equal<Concat<['1', 2, '3'], [false, boolean, '4']>, ['1', 2, '3', false, boolean, '4']>>,
]
这一题比较简单,主要我们要知道 TS 类型中也可以使用 …
拓展运算符,在 es6 中拓展运算符用于展开数组或对象,这里不详细展开。
在使用拓展运算符的时候,记得先使用 extends 限制一下泛型的范围,它必须是一个数组才能进行展开。
实现 Include
-
在类型系统里实现 JavaScript 的
Array.includes
方法,这个类型接受两个参数,返回的类型要么是true
要么是false
。首先这一题如果需要通过所有的测试用例,那它的实现应该是简单题里最难的了,所以我把他放在这里压轴登场。
type Includes<T extends readonly any[], U> = any
/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Kars'>, true>>,
Expect<Equal<Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'>, false>>,
Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 7>, true>>,
Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 4>, false>>,
Expect<Equal<Includes<[1, 2, 3], 2>, true>>,
Expect<Equal<Includes<[1, 2, 3], 1>, true>>,
Expect<Equal<Includes<[{}], { a: 'A' }>, false>>,
Expect<Equal<Includes<[boolean, 2, 3, 5, 6, 7], false>, false>>,
Expect<Equal<Includes<[true, 2, 3, 5, 6, 7], boolean>, false>>,
Expect<Equal<Includes<[false, 2, 3, 5, 6, 7], false>, true>>,
Expect<Equal<Includes<[{ a: 'A' }], { readonly a: 'A' }>, false>>,
Expect<Equal<Includes<[{ readonly a: 'A' }], { a: 'A' }>, false>>,
Expect<Equal<Includes<[1], 1 | 2>, false>>,
Expect<Equal<Includes<[1 | 2], 1>, false>>,
Expect<Equal<Includes<[null], undefined>, false>>,
Expect<Equal<Includes<[undefined], null>, false>>,
]
这一题先不放答案,根据前面做过的题目,我们可以分析我们需要遍历数组类型 T ,将每一项与 U 进行对比,如果相同就返回 true
,不相同就返回 false
根据题目我们可能会很快想到一个答案:
type Includes<T extends readonly any[], U> = T[number] extends U ? true : false
这个答案乍一看没啥问题(GitHub 中很多用户提交的答案),通过 T[number]
将数组类型转为联合类型,再通过联合类型的分配原则与 U 类型进行对比,最终返回结果。但一看测试用例就知道是寄了。
从第一个测试用例 Equal<Includes<["Kars", "Esidisi", "Wamuu", "Santana"], "Kars">, true>
挂了我们可以分析出来现在根本判断不到数组中存在某个元素,我们分析一下上面的实现方式存在的问题。
- 首先没有满足 联合类型 的 泛型 使用 extends 进行条件判断的前置条件,即便使用
T[number]
将数组转为联合类型也不会将联合类型中的每一个类型一一进行分配对比,而是将整个联合类型与一个单独的类型进行对比,那么除了数组中只存在一个类型且与 U 相等的情况,其他情况都是返回 false 。如下例:
type Includes<T extends readonly any[], U> = T[number] extends U ? true : false;
type A = Includes<["Kars"], "Kars">; // A = true
type B = Includes<["Kars", "Esidisi"], "Kars">; // B = false
- 其次
extends
的条件判断虽然长得像三目运算符,但是它并不能判断两个类型完全相同,它只能判断是否为子集,比如true
和false
都是boolean
的子集,还有很多其他情况不能判断,例如 readonly 属性等。
type C = true extends boolean ? true : false; // C = true
type D = { readonly a: "A" } extends {a:"A"} ? true : false; // D = true
那么针对以上提出的两个问题进行优化,最终得到的工具类型如下:
type Includes<T extends readonly any[], U> = T extends [
infer FirstItem,
...infer RestItem
]
? Equal<FirstItem, U> extends true
? true
: Includes<RestItem, U>
: false;
第一个问题是遍历数组的问题,除了 in
关键字,我们还可以使用递归的方式进行数组遍历,要使用递归要先设置好跳出递归的条件,一般遍历数组的写法都是这样:
T extends [infer FirstItem, ...infer RestItem] ? 进行某些判断 : 结束遍历
将数组类型的泛型 T 进行条件判断,推导出 FirstItem
代表数组的第一项, RestItem
代表数组的剩余项,如果 T 里边一个元素都没有了就会走结束遍历的分支,反之走判断的分支。
Equal<FirstItem, U> extends true
? true
: Includes<RestItem, U>
在进行判断的分支中,我们将数组的第一项与 U 进行对比,这里我们没有使用 extends 进行判断,而是直接使用了类型体操的库中提供的 Equal
工具类型,它的实现如下:
export type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false
关于 Equal 这个工具类型的实现后面可能单独出一篇文章讲解,这里不详细展开。
当 Equal<FirstItem, U> extends true
满足的时候说明数组中存在当前元素,则返回 true
,否则调用 Includes
工具类型自身,此时传入的 RestItem 已经是去掉了第一个元素的数组项,如果一直没有满足条件的数组项,数组就慢慢的变短,直到里面一个元素都没有了就从第一步的条件判断中结束了递归调用。最终也是能通过所有的测试用例的:
总结
keyof ObjectType
可以将对象类型的键转换为联合类型,而 ArrayType[number]
可以将数组的每个值转换为联合类型,in
可以用于遍历联合类型,ObjectType[Key]
索引访问可以得到索引对应的值的联合类型,再配合条件判断 extends
以及 递归循环调用 这一系列的方法。根据题目要求灵活组合这些方法就是解题的关键。
简单题其实每一个题目都是为我们介绍可以用于转换或者推导类型的一个个关键字,基本上每一题使用一到两种转换就可以得到答案,这说明后面的中等题目和困难题目可能一题需要用到其中的很多个转换进行组合,对于思维和熟练度的要求就更高了。
转载自:https://juejin.cn/post/7163144869362270216