TS类型体操(五) 健身操——中等难度题目1

266 阅读7分钟

TS类型体操(五) 健身操 中等难度题目1

TS类型体操(一) 基础知识

TS类型体操(二) TS内置工具类1

TS类型体操(三) TS内置工具类2

TS类型体操(四) 操场搭建以及热身运动

继续玩类型体操,这次是中等难度的题目,随便挑一些题目,有些会稍微讲解我的解题思路。

还是那句话,我的答案只能算抛砖引玉,也许有更好的做法,欢迎批评指正!

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会报错
  • 虽然会报错,但要求新值依旧覆盖原值。

我们来一步一步实现每个要求:

  1. option 函数返回一个添加了指定键值对的对象。
type Chainable = {
  option<K extends string, V>(key: K, value: V): Record<K, V>
  get(): unknown
}

​ 这里用到了Record<K, V>,如果不想用它,我们也可以写成这种形式:{[x in K]: V}

  1. 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的父集就行,它与第一个结果交叉时,得到的都是第一个结果自身。

  1. 新增已存在的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换成其他非字符串类型也是能达到效果的,例如booleanobject,仅从题目给的测试用例来说,甚至用number都行,但还是用never最好。

  1. 新增已存在的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'>获得DogLookUp<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,返回一个新的函数 GG 拥有 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 就讲到过,只要弄懂了断言的用法,这道题是很简单。