Typescript类型体操摘选及解答

339 阅读12分钟

做类型体操有助于学习和掌握TS的一些高级技巧,以及拓宽开发时的类型编写思路(避免自己一言不合就写any😂),本人在Github上学到的以及自己日常接触到的一些经典案例会节选到本文档,并附上参考答案和自己的理解,供自己和他人练习(内容不定期更新)

开始之前

类型体操并不适合TS初学者,建议先对Typescript特别是其泛型的概念达到一定理解程度再进行练习,为此可以先参考学习以下文档

Typescript入门教程

深入理解Typescript

Let's go

以下题目可以使用Typescript游乐场进行练习,默认TS版本为4.3.5,建议按顺序练习

  1. 实现泛型Partial<T>,将T的属性全部变为可选

// 举例

type A = {

    a: string;

    b: number;

};

type T1 = Partial<A>; // expected to be { a?: string; b?: number }



// 参考答案

type Partial<T> = {

    [P in keyof T]?: T[P];

};

注解:keyof T取T的属性key,结果为key的联合类型;[P in xxx]遍历key联合类型;T[P]根据key取T中对应属性的类型

  1. 实现泛型DeepPartial<T>,将T的属性递归地变为可选

// 举例

type A = {

    a: string;

    b: {

        b1: number;

        b2: boolean;

    };

};

type T1 = DeepPartial<A>; // expected to be { a?: string; b?: { b1?: number; b2?: boolean;} }



// 参考答案

type DeepPartial<T> = {

    [P in keyof T]?: DeepPartial<T[P]>;

};

注解:泛型可以递归

  1. 实现泛型If<C, T, F>C为字面量类型true或者false,为真则取类型T否则取类型F

// 举例

type T1 = If<number, 1, 2>; // expected to cause error

type T2 = If<true, 1, 2>; // expected to be 1

type T3 = If<false, 1, 2>; // expected to be 2

type T4 = If<never, 1, 2>; // expected to be never

type T5 = If<boolean, 1, 2>; // expected to be never

type T6 = If<true | false, 1, 2>; // expected to be never



// 参考答案1: 简单但也够用,覆盖T1~T4

type If<C extends boolean, T, F> = C extends true ? T : F;



// 参考答案2: 那是相当地严谨,覆盖T1~T6, 初学者暂时可以不必看这个答案

type If<C extends boolean, T, F> = 

    [C] extends [never] ? never :

    [C] extends [true] ? T :

    [C] extends [false] ? F :

    never;

注解:热身练习,主要考察extends泛型约束和extends三元表达式。首先解释下extends泛型约束,A extends B其实就是表示 “A是B的子类型” ,然后extends三元表达式就是在此基础上加个问号❓A extends B ? X : Y意思就是 “A是B的子类型吗?是的话返回X,否则返回Y”。 再来解释参考答案1就很清晰了:首先我们期待传入的C是true或者false,所以要将其约束为boolean以避免传入奇怪的东西,再来就是具体判断C是true还是false,是的话返回T否则返回F,就这么简单。

  1. 实现泛型Unpromisify<T>T可能为Promise类型,如果是的话取出其resolve之后结果的类型,否则直接获取T

// 举例

type A = Unpromisify<string>; // expected to be string

type B = Unpromisify<Promise<string>>; // expected to be string



// 参考答案

type Unpromisify<T> = T extends Promise<infer Result> ? Result : T;

注解:这里主要是要理解infer X这个操作符(🚀十分强大),我的理解是,她在extends表达式里面占位,然后任性地给编译器出难题:“X是什么类型?”,不过好在TS编译器够聪明,当T被传入泛型后,编译器就能通过匹配&捕获的方式把X给推断出来,然后X这个类型就可以当作确定的类型随便用了。理解了这个操作符之后再来看这个参考答案,意思就是先判断T是否是一个Promise的子类型,如果是的话就infer(推断)出Result类型然后我们就取这个类型,如果不是的话就直接取T类型就好了。

  1. 实现泛型TupleToUnion<T>,它将元组类型转成其值类型的联合。

举例

type Arr = ['1', '2', '3']

const a: TupleToUnion<Arr> // expected to be '1' | '2' | '3'

参考答案:

type TupleToUnion<T extends unknown[]> = T[number];



type TupleToUnion<T> = T extends Array<infer Items> ? Items : never;



type TupleToUnion<T extends ArrayLike<any>> = T extends [infer F, ...infer Last] ? TupleToUnion<Last> | F : never

注解:答案1简单明了我很喜欢,先将T约束为元素类型未知的数组类型(即元组类型),然后遍历并联合T的索引类型(T[number]可以理解为T[0 | 1 | 2 | 3 ...] => T[1] | T[2] | T[3] ...)。

  1. 实现泛型First<T>,取出元组T的第一个元素的类型

// 举例

type head1 = First<['a', 'b', 'c']> // expected to be 'a'

type head2 = First<[3, 2, 1]> // expected to be 3



// 参考答案

type First<T extends any[]> = T extends [] ? never : T[0]

注解:首先元组不能为空,否则取不到第一个元素类型,因此要判断T extends [] ?。然后元组的第一个元素对应的key是0,所以元组的第一个元素的类型就是元组类型的key0对应的类型。另外要注意⚠️这里出现的'a',3这些都是字面量类型,是类型!不是常量, 这点初学的同学(我)特别容易搞混

  1. 实现泛型Last<T>,它接受一个元组T并返回其最后一个元素的类型

// 举例

type arr1 = ['a', 'b', 'c']

type arr2 = [3, 2, 1]

type tail1 = Last<arr1> // expected to be 'c'

type tail2 = Last<arr2> // expected to be 1



// 参考答案

type Last<T extends any[]> = [never, ...T][T["length"]];



// 另一种答案,更好理解

type Last<T extends any[]> = T extends [...infer _, infer X] ? X : never;

注解:稍微转换一下就比First<T>那个问题的难度高了不只一个梯次,有趣吧😂,这里最难想的地方在于怎么拿到最后一个元素的key(即下标,或者说index),学过编程的人都知道它是T的长度减1,那么T的长度怎么拿?所幸只要把T约束为一个数组类型它就一定有length这个属性,那么T["length"]就是长度的字面量类型,但是怎么给它减1呢??类型运算里面不能做加减法呀??还好高手在人间,有人想出了颇为风骚的操作,自行给T前面扩展一个元素(也就是那个[never, ...T]),那扩展以后长度变成length+1了,最后一个元素下标不就是length了吗😂,而且还有一点很巧妙:由于扩展的类型是never,所以当元组为空的时候得到的结果刚好为never!看到这个操作我真的服了。再附上精彩评论

不过还有另一种巧妙的方法👍: 用扩展符...和infer去捕获到最后一个元素的类型,理解起来更直观

  1. 实现泛型Pop<T>,它接受一个元组并去除其最后一个元素的类型

// 举例

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 Pop<T extends any[]> = T extends [...infer I, infer _] ? I : never

注解:infer的妙用

  1. 实现泛型Reverse<T>,它接受一个元组并翻转其元素类型

// 举例

type arr1 = ['a', 'b', 'c', 'd']

type arr2 = [3, 2, 1]

type re1 = Reverse<arr1> // expected to be ['d', 'c', 'b', 'a']

type re2 = Reverse<arr2> // expected to be [1, 2, 3]



// 参考答案

type Push<T extends any[], Item> = [...T, Item];

type Reverse<T extends any[], E extends any[] = []> = {

    0: E;

    1: Reverse<Pop<T>, Push<E, Last<T>>>

} [T extends [] ? 0 : 1];

注解:类型运算中没有if else,但可以通过本例中的方式(映射代替if else,递归代替循环)去遍历元组类型

  1. 实现泛型IsUnion<T>,它可以判断T是否为联合类型。

举例

type case1 = IsUnion<string>  // false

type case2 = IsUnion<string|number>  // true

type case3 = IsUnion<[string|number]>  // false

参考答案:

type IsUnion<T, B = T> = T extends B ? [B] extends [T] ? false : true : never;

注解:看似很荒谬,实则挺合理(TS的世界观经常给人一种曲径通幽处的感觉),根据在下目前的所学,估计这里用到的原理是distributive-conditional-types,这个原理是说传入泛型的T如果是联合类型且遇到了extends语句的话,联合的各个类型会被拆分开来单独计算,算完再重新联合(类似小学数学里的分配律了),举个例子:

// 泛型中使用conditional type(即extends语句)时传入的T会被当成union(即联合类型)并进行拆分计算

type ToArray<T> = T extends any ? T[] : never;

// T1结果为number[] | string[]而非直觉中的(number | string)[]

// 因为T1等于ToArray<number> | ToArray<string>

type T1 = ToArray<number | string>; 



// 要阻止这种现象可以使用元组

type ToArray<T> = [T] extends [any] ? T[] : never;

type T1 = ToArray<number | string>; // 结果为(number | string)[]

明白了这个原理再来解释IsUnion的逻辑:第一个extends其实就是为了利用上述原理将联合类型拆开,这样一来表达式里实际执行的T其实是入参T的子类型,,而B才是T的全类型。T如果不是联合类型的话子类型和全类型是一致的,反之则不一致, 第二个extends正是利用这个原理来得出结论!(注意它还利用元组阻止了B被拆分~),[B] extends [T]成立的话,说明全类型跟子类型是一致的,那就说明最开始的入参T不是联合类型,反过来就说明是🎯。

另外还有件重要的事情提醒一下:学会了distributive-conditional-types原理之后请再去看一下原生泛型ExcludeExtract的源码,如果你感到豁然开朗了请回来帮我这个文档点个赞吧😎

  1. 实现泛型IsNever<T>,当T为never类型时返回字面量类型true,否则返回false

举例:

type A = IsNever<never>  // expected to be true

type B = IsNever<undefined> // expected to be false

type C = IsNever<null> // expected to be false

type D = IsNever<[]> // expected to be false

type E = IsNever<number> // expected to be false

参考答案:

type IsNever<T> = [T] extends [never] ? true : false 

注解:直觉会容易写成type IsNever<T> = T extends never ? true : false 但不能这样写,因为在泛型中never不能extends never(否则结果会直接变成never)。不过[never]可以extends [never]

  1. 给定数组,转换为对象类型,键/值必须在给定数组中。

举例

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const



// expected { tesla: 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}

const result: TupleToObject<typeof tuple>;

参考答案:

type TupleToObject<T extends readonly PropertyKey[]> = {

  [K in T[number]]: K;

};

注解: type PropertyKey = string | number | symbol,将T的元素类型约束为可以当作key的几种类型

  1. 实现泛型Capitalize<T>,它可以将字符串字面量类型T转成首字母大写。

举例

type capitalized = Capitalize<'hello world'> // expected to be 'Hello world'

参考答案:

type Capitalize<S extends string> = S extends `${infer x}${infer tail}` ? `${Uppercase<x>}${Lowercase<tail>}` : S;

注解:这里用到的技巧包括infer、模版字面量(用法类似JS)、Uppercase/Lowercase(原生泛型,作用是把字符串字面量类型转成大写/小写)。先infer捕获S的首字母和其余部分,然后用原生泛型将两部分分别进行大小写转换,最后用模版字符串的形式拼接首字母与其余部分。

  1. 实现泛型CapitalizeWords<T>,它可以将字符串字面量类型T中的每个单词转成首字母大写。

举例

type capitalized = CapitalizeWords<'hello world, my friends'> // expected to be 'Hello World, My Friends'

参考答案:

type CapitalizeWords<S extends string> =

  S extends `${infer L} ${infer R}` ? `${CapitalizeWords<L>} ${CapitalizeWords<R>}` :

  S extends `${infer L},${infer R}` ? `${CapitalizeWords<L>},${CapitalizeWords<R>}` :

  S extends `${infer L}.${infer R}` ? `${CapitalizeWords<L>}.${CapitalizeWords<R>}` :

  Capitalize<S>;

注解:与刚才的Capitalize相比infer匹配模式更多了,支持识别三种多单词模式,如果都不匹配的话认为只有一个单词,则降级到Capitalize即可

  1. 实现泛型CamelCase<T>,T是以下划线连接的单词,将其转为camel case字符串类型。

举例

type camelCase1 = CamelCase<'hello_world_with_types'> // expected to be 'helloWorldWithTypes'

type camelCase2 = CamelCase<'HELLO_WORLD_WITH_TYPES'> // expected to be same as previous one

参考答案:

type FirstUpperWord<T extends string> = T extends `${infer L}${infer R}` ? `${Uppercase<L>}${Lowercase<R>}` : '';

type PascalCase<T extends string> = 

  T extends `${infer L}_${infer R}` 

  ? `${PascalCase<L>}${PascalCase<R>}`

  : FirstUpperWord<T>;

type CamelCase<T extends string> = PascalCase<T> extends `${infer L}${infer R}` ? `${Lowercase<L>}${R}` : never;

注解:用infer匹配下划线两端的单词,递归处理

  1. 实现泛型RemoveIndexSignature<T>,移除T的下标类型

// 举例

type Foo = {

  [key: string]: any;

  foo(): void;

}



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





// 参考答案

type ExcludeSignature<K> = string extends K ? never : number extends K ? never : symbol extends K ? never : K;



type RemoveIndexSignature<T> = {

  [K in keyof T as ExcludeSignature<K>]: T[K]

}

注解:我理解这里的意思是普通key的类型为字符串字面类型,而下标的类型是string、number或symbol,根据这个原理去过滤。另外这里还用到了一个高级技巧:遍历时强转,强转结果为never时结果会被过滤掉

  1. 实现泛型Camelize<T>,递归地将T的下划线key转为驼峰

// 举例

// A expected to be

// {

//   someProp: string, 

//   prop: { anotherProp: string },

//   array: [{ snakeCase: string }]

// }

type A = Camelize<{

  some_prop: string, 

  prop: { another_prop: string },

  array: [{ snake_case: string }]

}>



// 参考答案

type CamelizeKey<T> = T extends string ? T extends `${infer F}_${infer L}` ? `${F}${CamelizeKey<Capitalize<L>>}` : T : never

type Camelize<T> = T extends any[] ? [Camelize<T[number]>] : {

  [P in keyof T as `${CamelizeKey<P>}`]: T[P] extends Record<string, any> ? Camelize<T[P]> : T[P];

}

注解:这个参考答案用到的主要技巧是在遍历时对P进行强转,以及判断了两种递归的情况,但对数组的递归处理其实存在问题(会把元组类型[A, B]变成[A|B])

  1. 实现泛型Chainable,使得链式调用结果可以自动生成类型

举例

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

  }

}

参考答案:

type Chainable<T = {}> = {

  get(): T;

  option<K extends string, V>(key: K, value: V): Chainable<Omit<T,K> & Record<K, V>>;

};

注解:一个Chainable类型链式调用option方法后仍然返回Chainable类型但get方法返回的类型发生了动态变化

  1. 实现一个范型 AppendArgument<Fn, A>,对于给定的函数类型 Fn,以及一个任意类型 A,返回一个新的函数 GG 拥有 Fn 的所有参数并在末尾追加类型为 A 的参数。

// 举例

type Fn = (a: number, b: string) => number

type Result = AppendArgument<Fn, boolean> // expected to be (a: number, b: string, x: boolean) => number



// 参考答案

type AppendArgument<Fn, A> = Fn extends (...args: infer Args) => infer Result ? (...args: [...Args, A]) => Result : never;

注解:infer + 元组扩展符,好好学完前面的案例这里应该就不需要详细解释了

  1. 定义函数PromiseAll的类型,它接受PromiseLike对象的数组,返回值应为Promise<T>,其中T是解析的结果数组。

// 举例

const promise1 = Promise.resolve(3);

const promise2 = 42;

const promise3 = new Promise<string>((resolve, reject) => {

  setTimeout(resolve, 100, 'foo');

});



const p = PromiseAll([promise1, promise2, promise3]) // expected to be `Promise<[number, number, string]>`



// 参考答案

declare function PromiseAll<T extends any[]>(arr: readonly [...T]): Promise<{ [K in keyof T]: T[K] extends Promise<infer R> ? R : T[K] }>;

注解:主要迷惑点在于arr: readonly [...T]为什么要这么写?因为如果直接写成arr: T的话推断出的入参类型为(number | Promise<number> | Promise<string>)[]而参考答案的这种写法可以得到入参类型[Promise<number>, number, Promise<string>]孰优孰劣一目了然,这也算是一个高级技巧吧~