TS类型体操(五) 健身操 中等难度题目1
继续玩类型体操,这次是中等难度的题目,随便挑一些题目,有些会稍微讲解我的解题思路。
还是那句话,我的答案只能算抛砖引玉,也许有更好的做法,欢迎批评指正!
12 - Chainable
题目
在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?
在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value) 和 get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。
这道题从描述来说不是很难,但要满足全部测试用例的要求也不太容易。
我们先来看测试用例
declare const a: Chainable
const result1 = a
.option('foo', 123)
.option('bar', { value: 'Hello World' })
.option('name', 'type-challenges')
.get()
const result2 = a
.option('name', 'another name')
// @ts-expect-error
.option('name', 'last name')
.get()
const result3 = a
.option('name', 'another name')
// @ts-expect-error
.option('name', 123)
.get()
type cases = [
Expect<Alike<typeof result1, Expected1>>,
Expect<Alike<typeof result2, Expected2>>,
Expect<Alike<typeof result3, Expected3>>,
]
type Expected1 = {
foo: number
bar: {
value: string
}
name: string
}
type Expected2 = {
name: string
}
type Expected3 = {
name: number
}
从测试用例来看,此题的要求是:
Chainable类型有两个函数option函数返回一个添加了指定键值对的对象,且要求支持链式调用,也就是说,其返回值也得是一个Chainable类型get函数返回最终结果
- 新增已存在的key会报错
- 虽然会报错,但要求新值依旧覆盖原值。
我们来一步一步实现每个要求:
option函数返回一个添加了指定键值对的对象。
type Chainable = {
option<K extends string, V>(key: K, value: V): Record<K, V>
get(): unknown
}
这里用到了Record<K, V>,如果不想用它,我们也可以写成这种形式:{[x in K]: V}。
option支持链式调用,get函数返回最终结果。
type Chainable<R = unknown> = {
option<K extends string, V>(key: K, value: V): Chainable<Record<K, V> & R>
get(): R
}
链式调用需要包含上一次的结果,所以我们利用泛型R,给定初始类型为unknown,然后每调用一次option,就进行一次交叉类型运算。如果你对这种操作有疑问,建议去看我系列第一篇文章关于对象交叉类型的部分内容。
这里unknown可以换成object或{},只要是Record的父集就行,它与第一个结果交叉时,得到的都是第一个结果自身。
- 新增已存在的key会报错
type Chainable<R = unknown> = {
option<K extends string, V>(key: K extends keyof R ? never : K, value: V): Chainable<Record<K, V> & R>
get(): R
}
如果key已经存在,对key的类型要求会变成never,此时如果key是个字符串,TS当然就会报错。
理论上我们将never换成其他非字符串类型也是能达到效果的,例如boolean、object,仅从题目给的测试用例来说,甚至用number都行,但还是用never最好。
- 新增已存在的key覆盖原值
type Chainable<R = unknown> = {
option<K extends string, V>(key: K extends keyof R ? never : K, value: V): Chainable<Record<K, V> & Omit<R, K>>
get(): R
}
这里用到了Omit<R, K>,会将R中已经存在的key排除掉,这样求交叉类型时,只会有新增的key,达到了覆盖原值的目的。
62 - Type Lookup
题目
有时,您可能希望根据某个属性在联合类型中查找类型。
在此挑战中,我们想通过在联合类型Cat | Dog中搜索公共type字段来获取相应的类型。换句话说,在以下示例中,我们期望LookUp<Dog | Cat, 'dog'>获得Dog,LookUp<Dog | Cat, 'cat'>获得Cat。
这个题本质上其实很像TS内置类Exclude,将一个联合类型中的某些类型排除掉。
type LookUp<U, T> = U extends { type: T } ? U : never
仅从解题的角度来说,上面这个就够了,但我们可以将它改得更加通用:
type LookUp<U, T, K extends string = 'type'> = U extends { [key in K]: T } ? U : never
通过第三个泛型参数K,能让它支持任意字段。
106 - Trim Left
实现 TrimLeft<T> ,它接收确定的字符串类型并返回一个新的字符串,其中新返回的字符串删除了原字符串开头的空白字符串。
这个题需要用到我之前没有提到、但很简单的一个技巧——字符串的切割与拼接。
type TrimLeft<S extends string> = S extends `${' ' | '\n' | '\t'}${infer R}` ? TrimLeft<R> : S
其实就是类似JS的模板字符串,这题的逻辑也很简单:如果S左端有空格,就提取右端的部分进行递归,如果没有就返回它自身。
第108号题Trim,要求删除两端的空格,其原理是一样的:
type SpaceString = ' ' | '\t' | '\n'
type Trim<S extends string> = S extends `${SpaceString}${infer R}` | `${infer R}${SpaceString}` ? Trim<R> : S
191 - Append Argument
题目
实现一个泛型 AppendArgument<Fn, A>,对于给定的函数类型 Fn,以及一个任意类型 A,返回一个新的函数 G。G 拥有 Fn 的所有参数并在末尾追加类型为 A 的参数。
说白了就是要给函数增加一个参数,不难。
type AppendArgument<Fn extends Function, A> = Fn extends (...args: infer T) => infer R ? (...args: [...T, A]) => R : Fn
只要熟悉infer的使用,个人觉得这题比easy的个别题目还要容易些。
296 - Permutation
题目
实现联合类型的全排列,将联合类型转换成所有可能的全排列数组的联合类型。
这道题是有一定难度的,想要做对这道题,需要我们熟悉我在第一篇文章就提到过的联合类型分发,否则给你答案你可能都看不懂:
type Permutation<T, K = T>
= [T] extends [never]
? []
: K extends infer F
? [F, ...Permutation<Exclude<T, F>>]
: []
这答案真的只能用一句话来形容:“懂的都懂”,用文字很难表达清楚,我这里只做一点提示:
首先我们可以先将关于never的判断去了:
type Permutation<T, K = T>
= K extends infer F
? [F, ...Permutation<Exclude<T, F>>]
: []
然后,我再解释一下联合类型分发:在这个例子中,TS在做K extends infer F判断时,如果K是联合类型,会对它进行分发(你可以理解为遍历),但是却不会对F进行分发:
type t = Permutation<'A' | 'B' | 'C'>;
// 就相当于↓
type t =
('A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : [])
| ('B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : [])
| ('C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : [])
剩下的大家自己琢磨吧。
1042 - IsNever
题目
Implement a type IsNever, which takes input type T.
If the type of resolves to never, return true, otherwise false.
这道题目前没有中文翻译,但意思很简单,就是要判断一个类型是否是never,在上一题中其实就用到了这个技巧。
type IsNever<T> = [T] extends [never] ? true : false
这道题被放进中等难度里,完全名不副实,但也值得稍微解释一下,为什么这样不行:
type IsNever<T> = T extends never ? true : false
除了存在any的问题以外,never本身也有其特殊之处。
我在系列第一篇文章中曾经验证过这个:
type a1 = never extends never ? true : false // true
never是自身的子集,这没什么问题,但是如果使用泛型:
type IsNever<T> = T extends never ? true : false
type a2 = IsNever<never> // never
结果竟然是never!
其实这个问题依旧是联合类型分发导致的,TS会将never视作一个没有任何成员的联合类型,计算never extends ...时会直接返回never。
所以,不只是never自身,extends后面换成其他任何类型,其结果都是never。
type a3 = never extends string ? true : false // true
type IsString<T> = T extends string ? true : false
type a4 = IsNever<never> // never
2595 - PickByType
题目
From T, pick a set of properties whose type are assignable to U.
这道题目前也没有翻译,意思就是实现一个Pick的值类型版,Pick是提取指定键类型,而PickByType是提取指定值类型。
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: U
}
这道题的实现原理我在 TS类型体操(二) TS内置工具类1 就讲到过,只要弄懂了断言的用法,这道题是很简单。