做类型体操有助于学习和掌握TS的一些高级技巧,以及拓宽开发时的类型编写思路(避免自己一言不合就写any😂),本人在Github上学到的以及自己日常接触到的一些经典案例会节选到本文档,并附上参考答案和自己的理解,供自己和他人练习(内容不定期更新)
开始之前
类型体操并不适合TS初学者,建议先对Typescript特别是其泛型的概念达到一定理解程度再进行练习,为此可以先参考学习以下文档
Let's go
以下题目可以使用Typescript游乐场进行练习,默认TS版本为4.3.5,建议按顺序练习
-
实现泛型
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中对应属性的类型
-
实现泛型
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]>;
};
注解:泛型可以递归
-
实现泛型
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,就这么简单。
-
实现泛型
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类型就好了。
-
实现泛型
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] ...
)。
-
实现泛型
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
这些都是字面量类型,是类型!不是常量, 这点初学的同学(我)特别容易搞混
-
实现泛型
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去捕获到最后一个元素的类型,理解起来更直观
-
实现泛型
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的妙用
-
实现泛型
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,递归代替循环)去遍历元组类型
-
实现泛型
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原理之后请再去看一下原生泛型Exclude
和Extract
的源码,如果你感到豁然开朗了请回来帮我这个文档点个赞吧😎
-
实现泛型
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]
-
给定数组,转换为对象类型,键/值必须在给定数组中。
举例
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的几种类型
-
实现泛型
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的首字母和其余部分,然后用原生泛型将两部分分别进行大小写转换,最后用模版字符串的形式拼接首字母与其余部分。
-
实现泛型
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
即可
-
实现泛型
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匹配下划线两端的单词,递归处理
-
实现泛型
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时结果会被过滤掉
-
实现泛型
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])
-
实现泛型
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方法返回的类型发生了动态变化
-
实现一个范型
AppendArgument<Fn, A>
,对于给定的函数类型Fn
,以及一个任意类型A
,返回一个新的函数G
。G
拥有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 + 元组扩展符,好好学完前面的案例这里应该就不需要详细解释了
-
定义函数
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>]
孰优孰劣一目了然,这也算是一个高级技巧吧~