在读完 TypeScript 全面进阶指南 小册后,准备尝试写一个解读 type-challenges 的系列文章,对所学的内容进行巩固,也希望能对 typescript
有更宽阔的了解。
该系列文章在大的逻辑上会根据题集的困难程度进行划分,简单部分涉及到的基础知识比较少,分为一篇文章解析,中等、困难、地狱系列的题目会根据具体涉及的知识篇幅划分。
在小的逻辑上,会根据题目为切入点,先分析实现思路,同时也会触类旁通相似的题目,最后给出解答(解答可能不是最好、最精确的,但是肯定能通过测试用例)。
如果感兴趣的话,可以持续关注。
type-challenges 最基础的知识
严谨来说,TypeScript 由三个部分组成:类型、语法、工程 。 type-challenges 的题目涉及到的是类型部分,这部分除了变量、函数等基础类型的标注, 最重要的就是各类类型工具的实现和使用,所以这里再把类型工具涉及到的最基础也是最重要的知识复述一下, 其他知识点在题目中再提及。
类型工具
在 type-challenges 的题目中绝大部分题目是实现一个符合 XXX 功能的类型工具,所以了解类型工具很重要。TS 内置了像 Pick
Partial
Required
Record
等类型工具,他们都基于类型别名
和泛型
实现。
类型别名用于封装一组或者一个特定类型。
ts
type A = string;
// 抽离一组联合类型
type StatusCode = 200 | 301 | 400 | 500 | 502;
type PossibleDataTypes = string | number | (() => unknown);
// 声明一个对象类型,就像接口那样
type ObjType = {
name: string;
age: number;
}
如果在类型别名的基础上添加泛型坑位,就成了类型工具。它的基本功能仍然是创建类型,只不过工具类型能够接受泛型参数,实现更灵活的类型创建功能。从这个角度看,工具类型就像一个函数一样,泛型是入参,内部逻辑基于入参进行某些操作,再返回一个新的类型。
ts
type Factory<T> = T | number | string;
// 工具类型来做类型标注, 生成一个新的类型别名
type FactoryWithBool = Factory<boolean>;
const foo: FactoryWithBool = true;
在 TS 中,类型工具可以这样划分:
- 按照使用方式来划分,类型工具可以分成三类:操作符、关键字与专用语法。
- 按照使用目的来划分,类型工具可以分为 类型创建 与 类型安全保护。
不管是按照使用方式还是使用目的划分,类型工具都需要基于传入的泛型进行操作,如果泛型是一个简单类型,类型工具需要知道该泛型的类型, 如果泛型是一个对象,还需要知道对象 key、 value(键值对) 的情况。 这就涉及到了 索引类型
和 映射类型
。
索引类型
索引类型包含三个部分:
- 索引签名类型,用来快速声明一个键值类型一致的类型结构。
- 索引类型查询,使用关键词
keyof
它可以将对象中的所有键转换为对应字面量类型,然后再组合成联合类型。 - 索引类型访问,通过键的字面量类型访问这个键对应的键值类型。
ts
// 索引类型签名
interface AllStringTypes {
[key: string]: string;
}
// 索引类型查询
interface Foo {
linbudu: 1,
599: 2
}
type FooKeys = keyof AllStringTypes; // "linbudu" | 599
// 索引类型访问
interface Foo {
propA: number;
propB: boolean;
propC: string;
}
type PropLinbudu = Foo['propA']; // number
type PropFoo = Foo[keyof Foo]; // number | boolean | string
映射类型
映射类型和 js 中的 map相似,用于 基于键名映射到键值类型 , 通过关键词 in
可以访问一个对象类型包含的属性名和值类型。
ts
// 使用 keyof 获得这个对象类型的键名组成字面量联合类型, 然后通过 in 将这个联合类型的每一个成员映射出来.
// 最终 Stringify 类型工具修饰过的类型的值都是 string 类型
type Stringify<T> = {
[K in keyof T]: string;
};
interface Foo {
prop1: string;
prop2: number;
prop3: boolean;
prop4: () => void;
}
type StringifiedFoo = Stringify<Foo>;
// 等价于
interface StringifiedFoo {
prop1: string;
prop2: string;
prop3: string;
prop4: string;
}
上面的代码, 稍加改变, 就可以实现对一个对象类型的复制。
ts
// 这里的`T[K]`其实就是上面说到的索引类型访问
type Clone<T> = {
[K in keyof T]: T[K];
};
掌握了类型工具和索引、映射类型,对于题目 Readonly - 实现 Readonly (7) 就很容易了。
Readonly - 实现 Readonly (7)
实现
按照上面说到的复制类型工具,再给属性添加 readonly
修饰词就可以了
ts
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
拓展: Required、Partial 的实现
Required、Partial 是一对工具类型, 他们功能上是相反的, 一个是修饰对象类型的属性都必须声明, 一个修饰对象类型的属性为可选声明。在 ts 对象类型中, 使用 ?
修饰属性,可以声明该属性为可选属性,使用 -?
修饰属性,可以声明该属性为必须属性, 那么 Required、Partial 实现如下:
ts
type Partial<T> = {
[K in keyof T]?: T[K]
}
type Required<T> = {
[K in keyof T]-?: T[K]
}
Pick - 实现 Pick(4)
题目
不使用 Pick<T, K>
,实现 TS 内置的 Pick<T, K>
的功能。
从类型 T
中选出符合 K
的属性,构造一个新的类型。
例如:
ts
interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
}
前置知识: 条件类型
TS 中有一套类型层级,从最上面一层的 any 类型,到最底层的 never 类型, 这些类型从上到下是有兼容性的。
TS 中使用 extends 判断类型的兼容性(继承),而非判断类型的全等性,因为在类型层面中,对于能够进行赋值操作的两个变量,我们并不需要它们的类型完全相等,只需要具有兼容性
解答和分析
在内置的 Pick 类型工具中, 一般 K 值需要在对象类型的属性列表中, 所以这里需要使用到 extends 继承、keyof 索引查询的知识。
in
操作符在 类型映射
已经提到了, 之外,它也可以通过 key in object
的方式来判断 key 是否存在于 object 或其原型链上(返回 true 说明存在),来进行类型守卫。
ts
// keyof T 返回 T 的属性类型的联合类型 K 需要继承这个联合类型, 确保 K 在 T 的属性列表中
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
Tuple to Object - 元组转换为对象(11)
题目描述
将一个元组类型转换为对象类型,这个对象类型的键/值和元组中的元素对应。
例如:
TS
const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
type result = TupleToObject<typeof tuple> // expected { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}
分析
比较容易想到的是,借助索引类型、类型映射、继承的知识, 我们可以很容易的把元组类型转换成对象类型。 题目中可以看到,传入TupleToObject的泛型 tuple 使用 as const
做了类型断言,这样会给类型添加 readonly 属性 由于元组是特殊的数组, 这里使用 extends any[]
做了类型限制
ts
type TupleToObject<T extends readonly any[]> = {
[K in X]: K
}
剩下的就是确定 X 应该怎么声明了,X 需要表示的是 T 全部元素的内容。
在 TS 的结构化类型系统中,采用的是 鸭子类型, 也就是如果两个数据的结构是一致的,TS 就认为这两个类型是一致的,哪怕类型名称不同。元组是在不同索引位置定义数据类型,同样对象也可以通过设置类索引的属性设置数据类型。
ts
// 在声明对象时
type tuple = {
0: string,
1: number,
2: boolean
}
// 在 TS 的类型比较中,用对象类型定义元组是成立的
const tup: tuple = ['1', 1, true]
既然可以用对象表示元组,那么获取元组的元素类型使用索引类型访问就可以了
ts
type tuple = {
[p: string]: number
}
type tup = ['1', 1, true]
type stringTypes = tuple[string] // string
// tup 可以看作是索引都是 number 的对象类型
type allTypes = tup[number] // '1' | 1 | true
答案
ts
type TupleToObject<T extends readonly any[]> = {
[K in T[number]]: K
}
题目中需要注意例子中的两个细节。
const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
使用as const
做了一次类型断言,目的是将 tuple 中的元素转变成字符串字变量类型
, 如果没有进行类型断言,tuple 中元素将都会解析成string
类型.- 使用 TupleToObject 参数中使用
typeof
做了一次转换目的是将 tuple 转变成类型声明而不是值声明。
First of Array 第一个元素 (14)
题目
实现一个First泛型,它接受一个数组T并返回它的第一个元素的类型。
例如:
ts
type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]
type head1 = First<arr1> // 应推导出 'a'
type head2 = First<arr2> // 应推导出 3
分析
关于 infer 的用法
TypeScript 中支持通过 infer 关键字来在条件类型中提取类型的某一部分信息 , 通俗说 infer就是用来在条件类型 中声明占位变量的。 记得必须在条件类型中。
例如拿到数组中的值。
ts
type Swap<T extends any[]> = T extends [infer A, infer B] ? [B, A] : T;
上面代码, infer 在条件类型中声明了变量 A、 B, 并在返回值中颠倒了数组顺序。
解答
ts
// 使用解构解析出第一个,和其他数值, 结合 infer 获取到解析的值
type First<T extends any[]> = T extends [infer A, ...infer rest] ? A : never
Length of Tuple 获取元组长度 (18)
题目描述
创建一个Length泛型,这个泛型接受一个只读的元组,返回这个元组的长度。 例如:
ts
type tesla = ['tesla', 'model 3', 'model X', 'model Y']
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']
type teslaLength = Length<tesla> // expected 4
type spaceXLength = Length<spaceX> // expected 5
分析
这道题和第 11 题(元组转换成对象)相似,可以把元组看成是对象类型或者数组类型,这样的话元组类型上就会有一个 length
属性,代表元组的长度。
答案
ts
// 如果只是满足题目中的用例, 这个是 OK 的
type Length<T extends readonly any[]> = T['length'];
// 由于 any extends any[] 是可以为真的,那么在 Length 工具类可以传入 any, 所以需要使用 `{length: infer A}` 结构类型做一下限定
type Length<T extends readonly any[]> = T extends { length: infer A } ? A : never;
为什么 any extends any[]
成立?
抛去 TS 中的类型层级,我们可以把 any 看作是可以是任何类型,那么如果在条件类型中 any 作为比较值时, 一般情况下就只有两种可能,成立和不成立。 既然是两种可能,那么下面的
Result
也就有两种类型结果了。
ts
type Result = any extends 'linbudu' ? 1 : 2; // 1 | 2
也有不一般的情况。那就是被比较放是 unknown
类型。 因为 unknown
只允许赋值给声明为 any 或者 unknown 的类型,也就是 any 在 unknown 的子集里
ts
type Result = any extends unknown ? 1 : 2; // 1
// any 本来就是顶级类型
type Result2 = unknown extends any ? 1 : 2; // 1
Exclude 实现 Exclude(43)
题目
实现内置的 Exclude<T, U> 类型,但不能直接使用它本身。从联合类型 T 中排除 U 中的类型,来构造一个新的类型。
例如:
ts
type Result = MyExclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c'
解析
Exclude 属于内置的集合类工具类型,用来求出两个联合类型的差集, 除此之外还有交集、并集、补集 的内置工具。
实现这类集合类的工具需要使用到 条件类型 、条件类型分布式 特性, 条件类型分布式的特性即当 T、U 都是联合类型(视为一个集合)时,T 的成员会依次被拿出来进行 extends U ? T1 : T2
的计算,然后将最终的结果再合并成联合类型。
答案
因为需要求出 T 、 U 之间的差集, 这里用 T 的成员依次拿出来进行 extends U ? never : T
的计算,这样就可以把不是 U 子集的类型筛选出来,得出差集。
ts
type MyExclude<T, U> = T extends U ? never : T;
交集 Extract 的实现
ts
// 交集
type Extract<T, U> = T extends U ? T : never;
Awaited 返回 Promise对象的类型 (189)
题目
假如我们有一个 Promise 对象,这个 Promise 对象会返回一个类型。在 TS 中,我们用 Promise 中的 T 来描述这个 Promise 返回的类型。请你实现一个类型,可以获取这个类型。
例如:Promise,请你返回 ExampleType 类型。
ts
type ExampleType = Promise<string>
type Result = MyAwaited<ExampleType> // string
解析
这道题还是需要用到 infer 的知识, 我们可以使用 Promise<infer A>
的方法获取到 Promise
返回值类型
TS 中提供了一个符合 Promise+ 规范的内置工具类型 PromiseLike
它包含了 .then
.catch
等方法, 我们可以使用 PromiseLike<infer A>
更准确的获取。
答案
ts
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer A> ?
A extends PromiseLike<any> ?
MyAwaited<A>
: A
: never;
解答中使用递归的形式又做了一次 A extends PromiseLike<any>
的判断, 这是因为用例中有嵌套的情况, 使用递归的形式就可以解决多层嵌套了。
ts
type Z = Promise<Promise<string | number>>
If 实现 If(268)
实现一个 IF 类型,它接收一个条件类型 C ,一个判断为真时的返回类型 T ,以及一个判断为假时的返回类型 F。 C 只能是 true 或者 false, T 和 F 可以是任意类型。
例如:
ts
type A = If<true, 'a', 'b'> // expected to be 'a'
type B = If<false, 'a', 'b'> // expected to be 'b'
答案
直接用条件类型
ts
type If<C, T, F> = C extends true ? T : F
Concat 实现 concat(533)
题目
在类型系统里实现 JavaScript 内置的 Array.concat 方法,这个类型接受两个参数,返回的新数组类型应该按照输入参数从左到右的顺序合并为一个新的数组。
ts
type Result = Concat<[1], [2]> // expected to be [1, 2]
答案
这个比较简单, 直接使用解构的概念就可以了
ts
// 用例中有下面的情况 所以需要使用添加 readonly
// const tuple = [1] as const;
// Concat<typeof tuple, typeof tuple>
type Concat<T extends readonly any[], U extends readonly any[]> = [...T, ...U]
Includes 实现 Includes(898)
题目
在类型系统里实现 JavaScript 的 Array.includes 方法,这个类型接受两个参数,返回的类型要么是 true 要么是 false。
例如:
ts
type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`
解析
如果单从题目看的话,这个实现很简单。
根据 type-challenges 第11题, 可以把数组看作是属性是 number 的对象类型,那么就可以通过 T[number]
的形式拿到所有对象值类型的联合类型。
ts
type Includes<T extends any[], U> = U extends T[number] ? true : false
但是从测试用例来看, 这个题目肯定不属于简单的范畴。还有以下复杂场景是上面的实现不能满足的。
把上面的 U extends T[number]
表达式应用到用例中
ts
// { a: 'A' } extends {} 是成立的,结果会是 true
Expect<Equal<Includes<[{}], { a: 'A' }>, false>>
// false extends boolean 成立, 结果是 true
Expect<Equal<Includes<[boolean, 2, 3, 5, 6, 7], false>, false>>
// boolean extends true boolean 可以拆解为 true 和 false, 所以这里的结果有两种可能 true | false
// 如果是 true | false 构成的联合类型, 会被自动推导为 boolean 类型
Expect<Equal<Includes<[true, 2, 3, 5, 6, 7], boolean>, false>>
// { readonly a: 'A' } extends { a: 'A' } 成立, 只要目标类型的每个属性都可以在源类型中找到,并且相应属性具有相同的属性值类型,那么就认为目标类型可以赋值给源类型。 并且 TypeScript 中的只读属性和可写属性可以互相赋值。 (可写属性不能赋值给只读属性)
Expect<Equal<Includes<[{ a: 'A' }], { readonly a: 'A' }>, false>>
// { a: 'A' } extends { readonly a: 'A' } 成立
Expect<Equal<Includes<[{ readonly a: 'A' }], { a: 'A' }>, false>>
// 1 | 2 extends 1 会分别用 1 extends 1 和 2 extends 1 进行计算, 结果收 true | false , ts 会推导为 boolean
Expect<Equal<Includes<[1], 1 | 2>, false>>
// 1 extends 1 | 2 成立
Expect<Equal<Includes<[1 | 2], 1>, false>>
看完上面的用例,就发现最主要是要实现一个能跳过 TS 内部的类型变型,按照字面量级别的类型判断。 可以使用内置的 Equal 工具。 Equal(U, K) extends ? true : false
因为 T[number]
一个联合类型,使用 Equal 没法计算,可以借助 infer 占位符和递归的方式解析数组。
ts
type Includes<T extends readonly any[], U> = T extends [infer A, ...infer rest] ?
// 开始使用 Equal 进行单个比较, 如果相同就直接返回 true 否则就继续和剩下的元素进行比较
(Equal<U, A> extends true ?
true :
Includes<rest, U>)
: false;
Push、Unshift (3057, 3060)
题目
在类型系统里实现通用的 Array.push 。实现类型版本的 Array.unshift。
例如:
ts
type Result = Push<[1, 2], '3'> // [1, 2, '3']
type Result = Unshift<[1, 2], 0> // [0, 1, 2,]
解答
比较简单, 使用解构
ts
type Push<T extends any[], U> = [...T, U];
type Unshift<T extends any[], U> = [U, ...T];
Parameters 实现内置的 Parameters 类型 (3312)
解答
同样是使用infer 作为占位,解析出返回值
ts
type MyParameters<T extends (...args: any[]) => any> = T extends (...args: infer A) => any ? A : never
总结
简单题目部分使用到的基础知识就是类型别名、类型工具、索引、映射类型、条件类型,infer 模式匹配,这些是 TS 类型的基础,建议把 TypeScript 全面进阶指南 前 15 章一定要认真品读。
另外 type-challenges 第898题 在 github 上面虽然有很多解答,但是缺少解题思路和为什么要这样解答的思路,希望可以通过这篇文章能够理解。