typescript-challenges-medium(1-15)
ReturnType<T>
构造一种由函数类型的返回类型组成的类型。不使用内置 ReturnType
1 | type MyReturnType<T> = T extends (...args: never[]) => infer A ? A : never; |
核心知识点:
注意点:
这里需要注意条件类型的参数,当参数为 ...args: unknown[]
时,表示“未知”的类型。
1 | const fn1 = (v: boolean, w: any) => (v ? 1 : 2); |
对于函数 fn1,它的参数类型不符合这个签名,因为它的参数类型不是 unknown[]
,而是具体的 boolean
和 any
类型。此时条件类型 T extends (...args: unknown[]) => infer A
不会匹配到 fn1,因此 fn1Type 的类型将是 never。
提问:never
作为一个底部类型,应该不能赋予除了 never 意外的其他类型,为什么能命中 true
分支?
导致这个结果的原因是因为,never
任何类型的 subType
。
1 | type Check<T> = never extends T ? true : false; |
T extends (...args: never[]) => infer A
这意味着它是一个接受空参数列表的函数类型。由于 never
类型是空集合的子类型,因此任何具有参数的函数类型也可以被赋值给 (...args: never[]) => infer A
。这就解释了为什么条件类型的结果为 true
。
值得一提的是:
- 不相交类型的 inteserction 结果为 never:
1 | type result = 1 & 2; // 结果为never |
- 除了 never,没有其他类型是 never 的 subtype
1 | type Check<T> = never extends never ? false : T extends never ? true : false; |
- 布尔运算
1 | T | never; // 结果为T |
- 是任何类型的 subtype(上文已通过例子说明)
Omit<T, K>
通过从 Type 中选取所有属性,然后删除 Keys (字符串文字或字符串文字的并集)来构造类型。与 Pick 相反。
1 | type MyOmit<T, K extends keyof T> = { |
核心知识点:
关键点:
映射类型:使用了映射类型语法 [P in keyof T as …],其中 P 是类型 T 的属性名,… 部分是映射类型的主体。在这里,我们要排除属性 K,所以需要在映射类型主体中进行判断。
Readonly2<T, K>
实现通用 MyReadonly2<T,K>
,它接收两个类型参数 T 和 K。
K 指定应设置为只读的 T 属性集。如果不提供 K,则会像普通的 Readonly<T>
一样将所有属性设置为只读。
1 | type MyReadonly2<T, K extends keyof T = keyof T> = { |
核心知识点:
注意点:
- 联合类型:使用 & 运算符将两个类型合并为一个新类型。在这里,左边的类型是从 T 中排除了指定属性的部分,右边的类型是将指定属性设为只读的部分。
- 默认类型:当类型 T 为空的时候,默认为类型
keyof T
。为了兼容第二个参数可能为空的情况。
DeepReadonly<T>
实现通用的 DeepReadonly
我们可以假设,在本挑战中我们只处理对象。不需要考虑数组、函数、类等。但是,您仍然可以通过尽可能多地涉及不同的情况来挑战自己。
1 | type DeepReadonly<T> = { |
核心知识点:
注意点:
只考虑对象类型,不包括数组和函数,使用 never 类型更准确。因为它代表了一个不可能存在的值,如果 keyof T[p] 的结果是 never,则意味着属性 p 对应的值是一个空对象。这种判断方式更精确地满足对对象的需求,避免了将函数、数组等不希望的类型误判为对象。
TupleToUnion<T>
实现通用的 TupleToUnion<T>
,它将元组的值联合为一个。
1 | //answer1 |
核心知识点:
Chainable Options
可链选项在 Javascript 中很常用。但当我们切换到 TypeScript 时,你能正确地键入它吗?
在本挑战中,你需要键入一个对象或一个类(随你喜欢),以提供两个函数 option(key, value) 和 get()。在 option 中,你可以通过给定的 key 和 value 扩展当前的配置类型。我们应该通过 get 访问最终结果。
1 | type Chainable<T = {}> = { |
核心知识点:
#13951 实现思路解释的太好了,在这基础上稍加修饰。
实现思路:
- 可以使用
T = {}
来作为默认值与默认返回值,再通过递归传递T
即可实现递归全局记录(这里泛型T
作为一个全局变量,用于记录最终结果)。 option
是一个函数接收两个值:K
和V
,为了约束key
不可重复必须范型传入,value 是任意类型范型不做约束直接透传。
1 | type Chainable<T = {}> = { |
- 先验证重复 key,排除相同的 key。
1 | type Chainable<T = {}> = { |
- 然后发现案例 3 无法通过,案例 3 是传入了相同的 key 但类型不同,使用 Omit 去掉 T 类型中相同的 key 就好了。
1 | type Chainable<T = {}> = { |
Last<T>
实现通用 Last
1 | //answer1 |
核心知识点:
实现思路:
- answer1 不用多解释了,与 First 的思路一样,只是取反了。
- answer2 思路是先将数组展开,然后通过
T["length"]
取数组长度,再通过索引访问数组,最后取到最后一个元素。
Pop<T>
实现一个通用的 Pop<T>
,它接收一个数组 T 并返回一个不含最后一个元素的数组。
1 | type Pop<T extends any[]> = T extends [...infer rest, infer L] ? rest : []; |
核心知识点:
与上面 Last<T>
的 answer1 类似,主要利用 infer
操作符区分最后一个元素。
Promise.all
键入接受 PromiseLike
对象数组的函数 PromiseAll
,返回值应为 Promise<T>
,其中 T 是已解析的结果数组。
1 | //answer1 |
核心知识点:
实现思路:
answer1 思路是先通过
readonly
将数组变为只读数组,然后通过[K in keyof T]
遍历数组,通过T[K] extends Promise<infer R>
判断是否为Promise
类型,如果是则通过R
获取Promise
的返回值,否则返回T[K]
,再判断T[K]
是否为number
,如果是则返回T[K]
,否则返回number
。answer2 为 answer1 简化版。
answer3 与 answer1 相似,只是省略了
Promise
条件判断,非number
类型的情况下,返回number
类型,依然能通过全部测试用例。
注意点:
as const
是一个断言字面量类型,用于将数组变为只读数组,这样就可以保证数组中的元素不会被修改。
尽管在 test1 和 test4 中,输入的元组都是[1, 2, 3]
,但是由于在 test4 中没有使用as const
来指定元组的类型,TypeScript 将其推断为一个通用类型的数组,而不是具有确定类型的元组。这导致了在 test1 和 test4 中返回的 Promise 类型不同的情况。
如果给 test4 加上as const
会怎样?
1 | const promiseAllTest4 = PromiseAll<Array<number | Promise<number>>>([ |
结果还是 [1, 2, 3] as const
和 <Array<number | Promise<number>>>([1, 2, 3] as const)
都将数组声明为只读数组,但是由于数组中的元素类型是联合类型 number | Promise<number>
,TypeScript 无法准确知道数组中每个元素的具体类型。因此,TypeScript 会根据联合类型的最小公共类型来推断返回的 Promise 类型,即 Promise<number[]>
。
LookUp<T, K>
1 | type LookUp<U, T> = U extends { type: T } ? U : never; |
核心知识点:
实现思路:
如果 U 类型具有一个名为 type
的属性,并且该属性的值类型与 T 类型相匹配,则返回 U 类型。
否则,返回的类型是 never,表示没有找到匹配的类型。
TrimLeft<T, K>
实现 TrimLeft<T>
,它接收精确的字符串类型,并返回一个去除了开头空格的新字符串。
1 | type Space = " " | "\t" | "\n"; |
Space
是一个联合类型,包括空格、制表符和换行符。Trim<S>
接受一个字符串类型 S 作为输入,并返回移除了开头、制表符和换行符后的新字符串类型。
使用条件类型S extends ${Space}${infer T}
来检查S
是否以空格、制表符或换行符开头或结尾。
如果S
开头有空格、制表符或换行符,则递归调用TrimLeft<T>
,其中T
是去除了开头一个字符后的子字符串。
如果S
不以空格、制表符或换行符开头,则直接返回原始字符串S
。
Trim<T, K>
实现 Trim<T>
,它接收精确的字符串类型,并返回去掉两端空白的新字符串。
1 | type Space = " " | "\t" | "\n"; |
核心知识点:
实现思路:
与 TrimLeft
类似,只是需要同时处理开头和结尾的空格。
使用条件类型 S extends ${Space}${infer T} | ${infer T}${Space}
来检查 S
是否以空格、制表符或换行符开头或结尾。
Capitalize<T, K>
实现 Capitalize<T>
,将字符串的第一个字母转换为大写字母,其余的保持不变。
1 | type MyCapitalize<S extends string> = S extends `${infer F}${infer T}` |
核心知识点:
实现思路:
与 TrimLeft
类似,通过条件类型判断并获取到第一个字符 F
, 再用 Uppercase<T>
将其转换为大写字母。
Replace<S, From, To>
实现 Replace<S,From,To>
,在给定字符串 S
中用 To
替换一次字符串 From
。
1 | type Replace< |
核心知识点:
ReplaceAll<S, From, To>
实现 ReplaceAll<S,From,To>
,在给定字符串 S
中用 To
替换所有From
字符串 。
1 | type ReplaceAll<S extends string, From extends string, To extends string> = |
核心知识点:
实现思路:
- 如果
From
为空字符串,则直接返回S
。 - 与
Replace
一样,将S
字符分成左半段L
和右半段R
,将条件类型推断出的From
替换成To
,然后递归调用ReplaceAll
函数, 继续处理右半段R
。
注意点:
在全部替换后 如果左半段 L
仍然存在 From
,则不需要继续替换。
比如在 666
中,ReplaceAll<'666','66','6'>
的结果是 66
,而不是 6
:1
ReplaceAll<'666','66','6'> // output: '66';
这也是 ${L}${To}${ReplaceAll<R, From, To>}
要保留 L
和 To
的原因。
$The\,End$
强烈推荐大家动手练习type-challenges
,通过思考和实践,相信你们能够轻松掌握其中的技巧。
以上内容为 type-challenges
部分的 1~15个挑战,后续继续更新剩下的内容。
感谢你的阅读,期望能够给你带来一些启发与收获。