头图

引言

在 TypeScript 玩转类型系列中,我们的目的就是拥有一手体操运动员的能力,解决工作中遇到 TS 问题的同时也能解决一下面试的问题(放心把: 熟悉 TypeScript 写上)。本文主要从解决实战题目,分析题目以及解题思路(掌握了,触类旁通)。题目包含这几个方面: Union 类型操作、数组操作、字符串操作、属性提取与类型合并等。包含题目 15+ , mid:14、 hard: 1 (实际题目20+左右,类似的题目未计入)。

话不多说,开搞~

字符串处理类

大部分类型体操基本都是在条件类型基础上进行的,只有借助条件类型才能进行遍历和递归,字符串类的处理基本都需要进行字符串模式匹配。条件类型涉及基础的三目运算符以及 extends 、 infer、keyof 等关键词。如果不熟悉这几个关键词的,可以先看看这篇文章

Trim<T> 「难度中等」

题目

实现将一个字符串的两侧空白字符进行清除、字符串中间的空白字符保留。相关题目: TrimLeft 只清除字符串左侧的空白字符。

type trimed = TrimLeft<'  Hello World  '> // 应推导出 'Hello World  '

实现思路

利用条件类型检查以及字符串的匹配能力,将空白字符匹配到并去掉,然后递归进行这个操作直到匹配的位置没有空白字符为止。【定义匹配的模板字符串条件、递归】

type Space = ' ' | '\n' | '\t'; // 空白字符包含三种

// 匹配字符串 S 左边的空白字符,匹配到了需要递归的处理(存在多个空白字符情况需要剔除)
type TrimLeft<S extends string> = S extends `${Space}${infer R}` ? TrimLeft<R> : S;

// 匹配右边的空白字符
type TrimRight<S extends string> = S extends `${infer L}${Space}` ? TrimRight<L> : S;

// 完整的 Trim 能力
type Trim<S extends string> = TrimRight<TrimLeft<S>>

需要注意,字符串匹配的顺序是从左到右,也就是从第一个字符匹配到最后一个字符,所以是顺序匹配的。匹配的顺序会固定我们得到目标字符前后的字符串的特征。比如:TrimLeft 第一次匹配的时候,匹配了第一个空白字符,然后剩余的字符都在 R 中,每次递归处理一个空白字符,直到匹配不上为止。

TrimLeft 小试牛刀传送门

Trim 小试牛刀传送门

ReplaceAll 「难度中等」

题目

  • 实现 Replace<S, From, To> 将字符串 S 中的第一个子字符串 From 替换为 To 。
  • 实现 ReplaceAll<S, From, To> 将一个字符串 S 中的所有子字符串 From 替换为 To。
type replaced = Replace<'types are fun!', 'fun', 'awesome'> // 期望是 'types are awesome!'
type replaced = ReplaceAll<'t y p e s', ' ', ''> // 期望是 'types'

实现思路

模板字符串匹配到对应的目标位置,替换成新的字符,全替换则递归整个过程,递归的时候注意顺序。

type ReplaceAll<S extends string, From extends string, To extends string> = From extends '' 
  ? S 
  : S extends `${infer First}${From}${infer Last}` 
  ? `${First}${To}${ReplaceAll<Last, From, To>}`
  : S;

单次替换的时候移除第四行的递归即可,千万注意不要对已经处理过的字符串再进行递归,如果对First 继续执行 ReplaceAll 递归那么得不到我们目标,还可能触发无限的循环(TypeScript 虽然限制了递归的深度)。

Replace 小试牛刀传送门

ReplaceAll 小试牛刀传送门

String#length 计算字符串的长度 「难度中等」

题目

实现计算一个字符串的长度计算类型。和JS中 String#length 效果一致。

实现思路

TypeScript 类型上能够读取 length 为具体数字的只有 Tuple 类型, 因此我们需要将字符串按照每个字符进行拆分转换成对应的 Tuple 类型。比如: "abcd" -> ['a', 'b', 'c', 'd'] 然后再从 Tuple 上读取 length 属性值。

type StrTransTuple<S extends string, T extends string[]> = S extends `${infer L}${infer R}`
  ? StrTransTuple<R, [...T,L]> : T['length'];
type LengthOfString<S extends string> = StrTransTuple<S, []>

注意区分Tuple与Array的区别,Tuple 定义完成后长度是固定的,因此可以推算长度,而Array则不是,从 Array 上读取 length 返回的是 number 这个类型并不是具体的数值。

小试牛刀传送门

Drop<S, D> 删除字符串上的指定字符或字符串「难度Hard」

题目

  • 删除一个字符
type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!'
  • 删除包含的多个字符
type Butterfly = DropString<'foobar!', 'fb'> // 'ooar!'

实现思路

条件类型遍历整个字符串,然后匹配删除(删除后使用 ' ' 空白字符进行位置占位),删除多个字符得先将目标串转成联合类型在进行检查判断。实现如下:

// string 转联合类型
type StrTransUn<S extends string> = S extends `${infer L}${infer E}` ? `${L}` | StrTransUn<E> : S;

type DropString<S, R extends string> = S extends `${infer F}${infer L}` ? F extends StrTransUn<R> ? `${DropString<L, R>}` : `${F}${DropString<L, R>}`: S;

这是一道 hard 的题目,其实也不难。所以,String 转 union 也实现了。

Drop 小试牛刀[传送门]()

DropString 小试牛刀传送门

小结

字符串的转换套路基本都一样,条件类型 + 模式匹配 + 递归。再结合一些需要转个弯的限制,比如我们需要提前转一下 Tuple 或者转 Union 、又或者需要从对象上读取属性名称进行匹配等。如果不理解模板字符串的匹配以及TypeScript 提供的字符串处理的基础能力可以看看我的这篇文章

接下来,看看数组类题目。

Array 和 Union 处理

TypeScript 中 Array 和 Union 是比较重要的类型之一,类型操作主要涉及到的是如何遍历 Tuple 数组、如何读取/修改指定位置的类型。如何处理 Union 转数组等问题。

IsTuple 判断「难度中等」

题目

给定一个类型 T,判断是否是 Tuple 类型,如果是返回 true,否则返回 false。

type notTuple = IsTuple<never> ; // false
type tuple = IsTuple<[number, string]>; // true

需要通过的 case:

type cases = [
  Expect<Equal<IsTuple<[]>, true>>,
  Expect<Equal<IsTuple<[number]>, true>>,
  Expect<Equal<IsTuple<readonly [1]>, true>>,
  Expect<Equal<IsTuple<{ length: 1 }>, false>>,
  Expect<Equal<IsTuple<number[]>, false>>,
  Expect<Equal<IsTuple<never>, false>>,
]

实现思路

对类型进行区别需要先识别其他类型与 [] 类型的区别,作为 [] 细分的继承类型需要判断是 any[] 还是 Tuple [number] 借助的是 length 属性的区别。Tuple 上的 length 是具体的数字,Array 上的length 属性是 number。

TypeScript 中用于对比两个值做判断的一般是 借助条件类型以及 extends 。比如判断是否是 true type IsTrue<T> = T extends true ? true : false , extends 关键词的背后接一个具体值,用于具体判断(虽然也是范围检查,但是具体到值的时候范围)。


type IsTuple<T> = [T] extends [never] ? false : T extends readonly any[] ? number extends T['length'] ? false : true : false

注意这里先处理 never , never 类型判断是采用 [T] extends [never]来进行判断、number extends ['length']用于判断是否是 Array 【这里用的排除法继承自 any[], 又不是Array,那就是Tuple】

IsTuple 小试牛刀传送门

Array#shift 、Array#pop 「难度中等」

题目

  • shift 从元组的头部移除一个,返回剩余的元组。
  • pop 从元组的尾部移除一个,返回剩余的元组。
type Result = Shift<[3, 2, 1]> // [2, 1]
type arr1 = ['a', 'b', 'c', 'd']
type arr2 = [3, 2, 1]

type re1 = Pop<arr1> // expected to be ['a', 'b', 'c']
type re2 = Pop<arr2> // expected to be [3, 2]

实现思路

条件类型,读取指定的类型进行废弃,比较简单。

type Shift<T> = T extends [infer F, ...infer Rest] ? [...Rest] : T
type Pop<T extends any[]> = T extends [...infer Rest, infer L] ? Rest : T

shift 小试牛刀传送门

pop 小试牛刀传送门

Array#filter 「难度中等」

题目

实现Filter<T, P> ,筛选过滤 T 中 符合P中的子项返回。

type R = Filter<[1,2,3], 1|2> ; // [1, 2]

实现思路

判断继承,将不符合P的筛掉,递归的进行判断得到结果。

type Filter<T extends any[], P> = T extends [infer F, ...infer Rest] ? F extends P ? [F, ...Filter<Rest, P>] : Filter<Rest, P>: T;

Filter 小试牛刀传送门

平铺 flatten 「难度中等」

题目

在这个挑战中,你需要写一个接受数组的类型,并且返回扁平化的数组类型。

type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5]

实现思路

与上一题思路上的区别是,递归执行的逻辑不同,需要判断子项是否是数组类型,如果是递归的先解开当前。因此这种思路属于是深度优先。

type Flatten<T extends any[], R extends any[] =[]> = T extends [infer S, ...infer E] ? S extends any[] ? Flatten<[...S, ...E], R> : Flatten<E, [...R, S]> : R; 

Flatten 小试牛刀传送门

联合类型组成的全排列 Permutation 「难度中等」

题目

实现联合类型的全排列,将联合类型转换成所有可能的全排列数组的联合类型。

type perm = Permutation<'A' | 'B' | 'C'>; 
// ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']

实现思路

要将联合类型 T 遍历生成他的全排列联合类型,需要递归进行遍历联合类型这里的递归和我们常见的有些不同是由: U extends U 触发联合类型第一次遍历类似于 foEach,然后我们手动递归剩余的部分组合。


type Permutation<T, U = T> = [T] extends [never] ? [] 
  : U extends U ? [U, ...Permutation<Exclude<T, U>>] : never;

这里的 U extends U 执行的过程如下:

最主要的是理解联合类型里面执行条件类型 U extends U时遍历时的执行顺序,比如:1 | 2 | 3 ,第一次遍历选中的是 1 ,剩余部分是 2 | 3,然后递归的从 2 | 3 里面继续遍历,直到 3 被遍历。而递归的选择逻辑是排除已经选中的项,在剩下的子项里面进行递归遍历。

还懵逼的小伙伴可移步 这里 ~

全排列小试牛刀 传送门

CheckRepeatedTuple<T> 检查是否存在重复项「难度中等」

题目

给定一个元组类型T,判断T中是否存在重复的元素,重复返回 true,否则返回false。

type CheckRepeatedTuple<[1, 2, 3]>   // false
type CheckRepeatedTuple<[1, 2, 1]>   // true

实现原理

要判断一个元组中是否存在重复项,需要从元组的第一个元素开始遍历与元组中剩下的进行判断,找到第一个存在的即可返回。因此遍历时,不用考虑当前项的前半部分只需要考虑后半部分中是否存在相同元素,前半部分不会出现和他相同的元素【如果存在,遍历到它之前就已经结束了】。

判断元组中是否存在某一元素采用: K extends U[number] 条件类型来进行判断,如果 K 存在于 U 元组类型中那么条件类型执行 True 分支。

type IsTuple<T> = [T] extends [never] ? false : number extends T['length'] ? false : true; 

type RepeatedTuple<T>  = T extends [infer R, ...infer Rest] ? R extends Rest[number]? true : RepeatedTuple<Rest>: false;

type CheckRepeatedTuple<T extends unknown[]> = IsTuple<T> extends true ? RepeatedTuple<T> : false

核心逻辑在 RepeatedTuple 中,IsTuple 是个判断是否是Tuple的检测类型,前面有介绍哈。

CheckRepeatedTuple 小试牛刀传送门

MergeAll 类型合并「难度中等」

题目

MergeAll<T> 将 T 元组类型中的所有元素合并成一个对象类型,实现如下效果:

type Foo = { a: 1; b: 2 }
type Bar = { a: 2 }
type Baz = { c: 3 }

type Result = MergeAll<[Foo, Bar, Baz]> // expected to be { a: 1 | 2; b: 2; c: 3 }

实现原理

思路,遍历元组类型,然后读取每个元素的属性名称以及类型,组合出一个新的对象类型,如果属性名重复的,该属性类型是各个对象对应类型组成的联合类型。

分步骤,第一步如何实现两个对象类型的合并?第二步加上遍历。

// 合并两个对象类型
type Merge<T, M> = { 
  [k in (keyof T) | (keyof M)]: k extends keyof T 
   ? k extends keyof M 
   ? T[k] | M[k] 
   : T[k] 
   : M[k]
}

// 遍历元组,进行合并
type MergeAll<T, R = {}> = T extends [infer I, ...infer Rest] 
  ? MergeAll<Rest, Merge<R, I>> : R;

MergeAll 小试牛刀传送门

至此,数组和元组的题目差不多啦,基本上包含了该类型解题的技巧~ 同类型的题目我进行了合并,有些就不重复去写了思路都是相通的。

杂类题目(不好分类的,放在这里了)

Omit「难度中等」

题目

实现一个类似于TypeScript 提供的 Omit 功能,不使用 Omit 实现 TypeScript 的 Omit<T, K> 泛型。

Omit 会创建一个省略 K 中字段的 T 对象。

例如:

interface Todo {   title: string   description: string   completed: boolean } 
type TodoPreview = MyOmit<Todo, 'description' | 'title'> 
const todo: TodoPreview = {   completed: false, }

实现原理

方法一 遍历T上的key,如果在P中,那么剔除掉(返回 never 作为key,会被自动筛掉)。方法二、Pick 组合 Exclude ,这是 TypeScript 官方实现的一种方式【属于是背诵源码实现了~】。

// 方法一、
type MyOmit<T, K> = { [P in keyof T as P extends K ? never: P ] : T[P] }

// 方法二、
type MyExclude<T, K> = T extends K ? never: T;
type MyPick<T, K extends keyof T> = { [P in K]: T[P] } 
type MyOmit<T, K> = MyPick<T, MyExclude<keyof T, K>>

Omit 小试牛刀传送门

Chainable 可串联的构造器「难度中等」

题目

在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value) 和 get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。

declare const config: Chainable

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get()

// 期望 result 的类型是:
interface Result {
  foo: number
  name: string
  bar: {
    value: string
  }
}

实现思路

主要是封装一下 option 函数的修饰类型处理两个部分【参数、返回值】,参数类型:根据option的定义不能重复之前的属性名(做个条件类型判断)、返回值类型:需要将当前传入的 K-V 与之前旧的类型进行合并(对已有的类型T进行递归的时候,需要剔除属性名和K相同的即可)。

type Chainable<T> = {
  option<K extends string, V>(key: K extends keyof T ? never: K, value: V): Chainable<Omit<T, K>> & Record<K, V>;
  get(): T
}

Chainable 小试牛刀传送门

RemoveIndexSignature移除索引签名「难度中等」

题目

移除类型上定义的索引签名

type Foo = {
  [key: string]: any
  foo(): void
}

type A = RemoveIndexSignature<Foo> // expected { foo(): void }

实现思路

遍历对象类型上的属性名,如果是索引类型的进行剔除。索引类型一般有三种申明范式: {[k: string]: any} {[K: number]: any} {[K: symbol]: any} 需要对遍历的K进行反向的类型检查( string extends string 全等比较,如果 K 是 string 那么 string extends K 成立,number 、 symbol 同理进行比较)。

type RemoveIndexSignature<T> = { 
  [ K in keyof T as number extends K ? never 
   : string extends K 
   ? never 
   : symbol extends K 
   ? never
   : K 
   ]: T[K] 
}

RemoveIndexSignature 小试牛刀传送门

就到这里了,还有很多类似的题目值得一试~看到这里大部分的题目已经能够 cover 啦。掌握题目不如掌握解题的方式,了解一下 extends 、 infer、keyof 、模板字符串的能力等是我们解体操类型的基础。没看的 xdm 【兄弟们】移步

总结

类型体操系列差不多就已经结束啦,三篇文章基本上能 cover 80% 的面试题了,也能加强我们对 TypeScript 的理解。有些基础文档上确实不会讲,多练练,多看看。TypeScript 英文官方文档里面有很多一手的信息,有时间的话可以翻翻。

参考资料


小乌龟快跑
15 声望2 粉丝