typescript-challenges-medium(1-15)

ReturnType<T>

Challenge Readme

构造一种由函数类型的返回类型组成的类型。不使用内置 ReturnType

1
type MyReturnType<T> = T extends (...args: never[]) => infer A ? A : never;

核心知识点:元组展开 ... 泛型推断 infer 条件类型 extends

注意点

这里需要注意条件类型的参数,当参数为 ...args: unknown[] 时,表示“未知”的类型。

1
2
const fn1 = (v: boolean, w: any) => (v ? 1 : 2);
type fn1ReturnType = MyReturnType<typeof fn1>;

对于函数 fn1,它的参数类型不符合这个签名,因为它的参数类型不是 unknown[],而是具体的 booleanany 类型。此时条件类型 T extends (...args: unknown[]) => infer A 不会匹配到 fn1,因此 fn1Type 的类型将是 never。

提问:never 作为一个底部类型,应该不能赋予除了 never 意外的其他类型,为什么能命中 true 分支?
导致这个结果的原因是因为,never 任何类型的 subType

1
2
type Check<T> = never extends T ? true : false;
type result = check<xxx>; // 结果始终为true

T extends (...args: never[]) => infer A 这意味着它是一个接受空参数列表的函数类型。由于 never 类型是空集合的子类型,因此任何具有参数的函数类型也可以被赋值给 (...args: never[]) => infer A。这就解释了为什么条件类型的结果为 true

值得一提的是:

  1. 不相交类型的 inteserction 结果为 never:
1
type result = 1 & 2; // 结果为never
  1. 除了 never,没有其他类型是 never 的 subtype
1
2
type Check<T> = never extends never ? false : T extends never ? true : false;
type result = check<xxx>; // 结果始终为false
  1. 布尔运算
1
2
T | never; // 结果为T
T & never; // 结果为never
  1. 是任何类型的 subtype(上文已通过例子说明)

Omit<T, K>

Challenge Readme

通过从 Type 中选取所有属性,然后删除 Keys (字符串文字或字符串文字的并集)来构造类型。与 Pick 相反。

1
2
3
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};

核心知识点:类型映射 as 条件类型 extends

关键点
映射类型:使用了映射类型语法 [P in keyof T as …],其中 P 是类型 T 的属性名,… 部分是映射类型的主体。在这里,我们要排除属性 K,所以需要在映射类型主体中进行判断。

Readonly2<T, K>

Challenge Readme

实现通用 MyReadonly2<T,K>,它接收两个类型参数 T 和 K。

K 指定应设置为只读的 T 属性集。如果不提供 K,则会像普通的 Readonly<T> 一样将所有属性设置为只读。

1
2
3
4
5
type MyReadonly2<T, K extends keyof T = keyof T> = {
[p in keyof T as p extends K ? never : p]: T[p];
} & {
readonly [p in K]: T[p];
};

核心知识点:联合类型 & 类型映射 as 条件类型 extends

注意点

  1. 联合类型:使用 & 运算符将两个类型合并为一个新类型。在这里,左边的类型是从 T 中排除了指定属性的部分,右边的类型是将指定属性设为只读的部分。
  2. 默认类型:当类型 T 为空的时候,默认为类型 keyof T。为了兼容第二个参数可能为空的情况。

DeepReadonly<T>

Challenge Readme

实现通用的 DeepReadonly,使对象及其子对象的每个参数都是只读的。

我们可以假设,在本挑战中我们只处理对象。不需要考虑数组、函数、类等。但是,您仍然可以通过尽可能多地涉及不同的情况来挑战自己。

1
2
3
type DeepReadonly<T> = {
readonly [p in keyof T]: keyof T[p] extends never ? T[p] : DeepReadonly<T[p]>;
};

核心知识点:递归类型 索引类型查询 keyof

注意点

只考虑对象类型,不包括数组和函数,使用 never 类型更准确。因为它代表了一个不可能存在的值,如果 keyof T[p] 的结果是 never,则意味着属性 p 对应的值是一个空对象。这种判断方式更精确地满足对对象的需求,避免了将函数、数组等不希望的类型误判为对象。

TupleToUnion<T>

Challenge Readme

实现通用的 TupleToUnion<T>,它将元组的值联合为一个。

1
2
3
4
//answer1
type TupleToUnion<T extends unknown[]> = T[number];
//answer2
type TupleToUnion<T extends unknown[]> = T extends (infer U)[] ? U : never;

核心知识点:索引访问操作符 T[number] 索引访问操作符 T[number]

Chainable Options

Challenge Readme

可链选项在 Javascript 中很常用。但当我们切换到 TypeScript 时,你能正确地键入它吗?

在本挑战中,你需要键入一个对象或一个类(随你喜欢),以提供两个函数 option(key, value) 和 get()。在 option 中,你可以通过给定的 key 和 value 扩展当前的配置类型。我们应该通过 get 访问最终结果。

1
2
3
4
5
6
7
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;
};

核心知识点:泛型默认值 T = {} 条件类型 extends 删除所选键 Omit  Record

#13951 实现思路解释的太好了,在这基础上稍加修饰。

实现思路:

  1. 可以使用 T = {} 来作为默认值与默认返回值,再通过递归传递 T 即可实现递归全局记录(这里泛型 T 作为一个全局变量,用于记录最终结果)。
  2. option 是一个函数接收两个值:KV,为了约束 key 不可重复必须范型传入,value 是任意类型范型不做约束直接透传。
1
2
3
4
5
6
7
type Chainable<T = {}> = {
option: <K extends string, V>(
key: K,
value: V
) => Chainable<T & Record<K, V>>;
get: () => T;
};
  1. 先验证重复 key,排除相同的 key。
1
2
3
4
5
6
7
type Chainable<T = {}> = {
option: <K extends string, V>(
key: K extends keyof T ? never : K,
value: V
) => Chainable<T & Record<K, V>>;
get: () => T;
};
  1. 然后发现案例 3 无法通过,案例 3 是传入了相同的 key 但类型不同,使用 Omit 去掉 T 类型中相同的 key 就好了。
1
2
3
4
5
6
7
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;
};

Last<T>

Challenge Readme

实现通用 Last,它接收数组 T 并返回其最后一个元素。

1
2
3
4
//answer1
type Last<T extends any[]> = T extends [...infer rest, infer L] ? L : never;
//answer2
type Last<T extends any[]> = [any, ...T][T["length"]];

核心知识点:条件类型 extends 元组展开 ...

实现思路:

  1. answer1 不用多解释了,与 First 的思路一样,只是取反了。
  2. answer2 思路是先将数组展开,然后通过 T["length"] 取数组长度,再通过索引访问数组,最后取到最后一个元素。

Pop<T>

Challenge Readme

实现一个通用的 Pop<T>,它接收一个数组 T 并返回一个不含最后一个元素的数组。

1
type Pop<T extends any[]> = T extends [...infer rest, infer L] ? rest : [];

核心知识点:条件类型 extends 元组展开 ...

与上面 Last<T> 的 answer1 类似,主要利用 infer 操作符区分最后一个元素。

Promise.all

Challenge Readme

键入接受 PromiseLike 对象数组的函数 PromiseAll,返回值应为 Promise<T>,其中 T 是已解析的结果数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//answer1
declare function PromiseAll<T extends any[]>(
values: readonly [...T]
): Promise<{
[K in keyof T]: T[K] extends Promise<infer R>
? R
: T[K] extends number
? T[K]
: number;
}>;

//answer2
declare function PromiseAll<T extends any[]>(
values: readonly [...T]
): Promise<{ [K in keyof T]: Awaited<T[K]> }>;

//answer3
declare function PromiseAll<T extends any[]>(
values: [...T]
): Promise<{ [K in keyof T]: T[K] extends number ? T[K] : number }>;

核心知识点:断言字面量类型literal type (as const) 泛型推断 infer

实现思路:

  1. answer1 思路是先通过 readonly 将数组变为只读数组,然后通过 [K in keyof T] 遍历数组,通过 T[K] extends Promise<infer R> 判断是否为 Promise 类型,如果是则通过 R 获取 Promise 的返回值,否则返回 T[K],再判断 T[K] 是否为 number,如果是则返回 T[K],否则返回 number

  2. answer2 为 answer1 简化版。

  3. answer3 与 answer1 相似,只是省略了 Promise 条件判断,非 number 类型的情况下,返回 number 类型,依然能通过全部测试用例。

注意点

  1. as const 是一个断言字面量类型,用于将数组变为只读数组,这样就可以保证数组中的元素不会被修改。
    尽管在 test1test4 中,输入的元组都是 [1, 2, 3],但是由于在 test4 中没有使用 as const 来指定元组的类型,TypeScript 将其推断为一个通用类型的数组,而不是具有确定类型的元组。这导致了在 test1test4 中返回的 Promise 类型不同的情况。
    如果给 test4 加上 as const 会怎样?
1
2
3
const promiseAllTest4 = PromiseAll<Array<number | Promise<number>>>([
1, 2, 3,
] as const);

结果还是 [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>

Challenge Readme

1
type LookUp<U, T> = U extends { type: T } ? U : never;

核心知识点:条件类型 extends

实现思路:
如果 U 类型具有一个名为 type 的属性,并且该属性的值类型与 T 类型相匹配,则返回 U 类型。
否则,返回的类型是 never,表示没有找到匹配的类型。

TrimLeft<T, K>

Challenge Readme

实现 TrimLeft<T>,它接收精确的字符串类型,并返回一个去除了开头空格的新字符串。

1
2
3
4
type Space = " " | "\t" | "\n";
type TrimLeft<S extends string> = S extends `${Space}${infer T}`
? TrimLeft<T>
: S;
  1. Space 是一个联合类型,包括空格、制表符和换行符。
  2. Trim<S> 接受一个字符串类型 S 作为输入,并返回移除了开头、制表符和换行符后的新字符串类型。
    使用条件类型 S extends ${Space}${infer T} 来检查 S 是否以空格、制表符或换行符开头或结尾。
    如果 S 开头有空格、制表符或换行符,则递归调用 TrimLeft<T>其中 T 是去除了开头一个字符后的子字符串
    如果 S 不以空格、制表符或换行符开头,则直接返回原始字符串 S

Trim<T, K>

Challenge Readme

实现 Trim<T>,它接收精确的字符串类型,并返回去掉两端空白的新字符串。

1
2
3
4
5
6
type Space = " " | "\t" | "\n";
type Trim<S extends string> = S extends
| `${Space}${infer T}`
| `${infer T}${Space}`
? Trim<T>
: S;

核心知识点:条件类型 extends 递归类型 推断 infer

实现思路:

TrimLeft 类似,只是需要同时处理开头和结尾的空格。
使用条件类型 S extends ${Space}${infer T} | ${infer T}${Space} 来检查 S 是否以空格、制表符或换行符开头或结尾。

Capitalize<T, K>

Challenge Readme

实现 Capitalize<T>,将字符串的第一个字母转换为大写字母,其余的保持不变。

1
2
3
type MyCapitalize<S extends string> = S extends `${infer F}${infer T}`
? `${Uppercase<F>}${T}`
: S;

核心知识点:条件类型 extends

实现思路:
TrimLeft 类似,通过条件类型判断并获取到第一个字符 F, 再用 Uppercase<T> 将其转换为大写字母。

Replace<S, From, To>

Challenge Readme

实现 Replace<S,From,To>,在给定字符串 S 中用 To 替换一次字符串 From

1
2
3
4
5
6
7
8
9
type Replace<
S extends string,
From extends string,
To extends string
> = From extends ""
? S
: S extends `${infer L}${From}${infer R}`
? `${L}${To}${R}`
: S;

核心知识点:条件类型 extends

ReplaceAll<S, From, To>

Challenge Readme

实现 ReplaceAll<S,From,To>,在给定字符串 S 中用 To 替换所有From字符串 。

1
2
3
4
5
type ReplaceAll<S extends string, From extends string, To extends string> =
From extends '' ?
S :
S extends `${infer L}${From}${infer R}` ?
`${L}${To}${ReplaceAll<R, From, To>}` : S;

核心知识点:条件类型 extends 递归类型 推断 infer

实现思路:

  1. 如果 From 为空字符串,则直接返回 S
  2. 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>} 要保留 LTo 的原因。

$The\,End$

强烈推荐大家动手练习type-challenges,通过思考和实践,相信你们能够轻松掌握其中的技巧。
以上内容为 type-challenges 97 部分的 1~15个挑战,后续继续更新剩下的内容。
感谢你的阅读,期望能够给你带来一些启发与收获。