TS 类型体操前置知识储备

6/30/2022 TS

# TS 类型体操前置知识储备

如果你正在学习 TS,可是像我一样仅仅停留在定义类型,定义 interface/type 的层面的话, 这份体操类型练习题一定不要错过 ! type-challenges (opens new window)

写这篇文章的时候我只做完了体操类型的中等级别的题目(还有 2-3 道其实没完全解出来)

# 简单搭建一下做题环境

我喜欢做一题拷贝一题,所以我 clone 了一份 type-challenges (opens new window)。然后在 type-challenges 新建了一个文件夹(my-type-challenges),专门用来做 TS 体操

每次想做题的时候都只需要记住编号,比如第一题 13-helloword 。只需要输入就可以了

npm run copy 13
1

详细的脚本在 Jioho/my-type-challenges (opens new window)。写了脚本后,就可以愉快的写代码了

# 体操入门基本语法

  • 以下的内容纯粹是个人的见解,如有说错或理解不到位的地方请指出

虽然说 TS 图灵完备(如果一个计算系统可以计算每一个图灵可计算函数,那么这个系统就是图灵完备的)

不过 TS 并没有那么多语法,比如 if,switch,return 之类的。

TS 用的最多的都是三目运算符,判断相等,数组,递归等一些技巧后面都会一一介绍到

# TS 内置的高级类型(内置的体操)

入门第一步一定要看文档(虽然我也不爱看,看不懂),不过还是需要有基础的了解 utility-types (opens new window)

目前的内置方法就如下:

--- --- --- ---
Partial<Type> Required<Type> Readonly<Type> Record<Keys, Type>
Pick<Type, Keys> Omit<Type, Keys> Exclude<UnionType, ExcludedMembers> Extract<Type, Union>
NonNullable<Type> Parameters<Type> ConstructorParameters<Type> ReturnType<Type>
InstanceType<Type> ThisParameterType<Type> OmitThisParameter<Type> ThisType<Type>
Uppercase<StringType> Lowercase<StringType> Capitalize<StringType> Uncapitalize<StringType>

比如拿一个后续可能用的比较多的来说一下:

Pick (opens new window) 方法

官网的 demo:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};
1
2
3
4
5
6
7
8
9
10
11
12

Pick 的作用就是从一个对象中,挑选需要的字段出来,比如从 TODO 里面只取出 titlecompleted

如果没有类型体操的话,TodoPreview 还得额外定义一个类型

interface TodoPreview {
  title:string;
  completed: boolean;
}
1
2
3
4

之前有一个练手项目我就是遇到了这样的情况,明明都是同一个对象上的字段,为了适应不同场景,硬是定义了一大堆的附属字段,关键是如果想改一个类型,还得全部跟着改。。如果有 Pick 就一了百了,想要啥就 pick 啥

想看 Pick 实现也很简单,随便起一个 type,按住 ctrl+点击 Pick 就可以看到 Pick 实现

代码实现如下:

/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
1
2
3
4
5
6

不过第一次看到这代码, extendskeyofin 这都是什么东西?这就是这篇文章存在的意义。往下看

# 判断常用 extends 关键字

extends 翻译是延伸的意思

在 TS 充当了 if在 XXX 范围内 的一个作用。extends 后面通常就要接三目运算符了(也有例外)

举个 🌰 子

type mytype1 = 1 extends string ? true : false // false

type mytype2 = '1' extends string ? true : false // true

type mytype2_1 = string extends '1' ? true : false // false

type mytype3 = mytype1 extends any ? 1 : 2 // 1

type mytype4 = [90] extends unknown[] ? true : false // true

type mytype5 = [90] extends string[] ? true : false // false
1
2
3
4
5
6
7
8
9
10
11

上面简单的举了几个例子,简单解释下:

  • 1 是否属于 string 类型 得到 false 因为 1 是数字类型
  • '1' 属于是 string 类型的 三目运算符判断为 true
  • string 类型 属于 '1' 肯定是 false 的,string 类型范围比'1'更大,不在属于的范畴了
  • mytype1 属于 any 类型是对的,因为 any 包含一切~
  • [90] 是一个数值型的数组,属于一个 unknown 未知类型的数组中,这个也是对的,因为未知类型也会包含数字类型
  • 而 [90] 就不属于 string[] 的范畴了

extends 也有不接三目运算符的时候

比如写一个限制 string 类型的 push 方法

type StrPush<T extends string[], U extends string> = [...T, U]
type myarr = StrPush<[1, 2, 3], 4>
1
2

比如在这个例子中,直接会报错。因为还没进到 StrPush 的判断中,T 泛型就已经被约束为 strinig 类型了,U 也被约束为 string 类型

# extends 总结

  • extends 在 TS 的 函数体中的时候起到的是判断范畴的一个作用
  • 在一些特殊位置 (比如接收泛型的时候,在函数运算过程中断言变量类型的时候)起到的是一个 约束类型 的作用

# 循环对象的键 keyof 和 in

只要了解 keyof 和 in 之后,Pick 的所有关键字也就讲解完了,体操练习中简单的第一题 MyPick 也就完成了

  • 还是上面的 demo 代码
/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

extends 上面讲过,属于范畴判断/约束类型,在泛型定义<>里面明显就是为了约束类型

keyof 的作用可以理解为 把一个对象中的所有 提取出来。

  • 直接用 keyof 提取一个对象看看效果:
type TodoKeys = keyof Todo
//type TodoKeys =  'title' | 'description' | 'completed'

type testkeys = keyof {[k:number]: any; name:string}
// type testkeys = number | "name"

// 特殊的例子
type Mapish = { [k: string]: boolean; name:string };
type M = keyof Mapish;
// type M = string | number
1
2
3
4
5
6
7
8
9
10

上面的几个例子中,第一个是最好理解的,提取所有的

第二个案例中,k 作为未知的内容,提取 number 作为 key 的范围,加上 "name" 所以就得出 number | "name"

特殊的例子也是官网的例子,为啥会有个 number 类型?

原话: Note that in this example, M is string | number — this is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].
翻译: 请注意,在此示例中,M string | number — 这是因为 JavaScript 对象键总是被强制转换为字符串,所以 obj[0] 总是与 obj["0"] 相同

结合 demo ,K extends keyof T 意思也就是说,K 参数的取值范围只能在 Todo 的键中取('title' | 'description' | 'completed'),限制为字符串,并且是这 3 个键中的其中一个/多个,只能少,不能多

如果是下面这种

type Mapish = { [k: string]: boolean; name:string };
// K extends keyof Mapish;
1
2

K 的范围则是 string | number 类型(因为没有具体的键名,所以只能不限制具体的键名,只限制类型

像这种用 | 拼起来的(或类型)规范点叫做 unio 联合数据类型,想要循环这些数据,可以用到 in 关键字


回到 demo 的讲解

  • Pick 中 泛型 T 传入的是 Todo 类型
  • K extends keyof T : K 限制为 Todo 的键值(传入的是 "title" | "completed" 符合要求,因为只能少不能多嘛)
  • P in k 可以理解为 循环
  • P 会依次被赋值为 title
  • 然后赋值 completed
  • T[P] 的意思和 JS 的[]取值一样,获取 T['title'] => string 和 T['completed'] => completed
  • {} 会包裹循环出来的结果
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

type TodoPreview = Pick<Todo, "title" | "completed">;
// TodoPreview = {title:string,completed:boolean}
1
2
3
4
5
6

Pick 的实现就完成了。[P in K] 这个循环和我们 JS 常规的写法很不一样,需要消化一下,其他应该都好理解


  • 以此内推,完成 Readonly
    • 众所周知 readonly 只需要在键值前面加上 readonly 参数即可

实现如下:keyof 写到了中括号里面,这个是允许的,这些关键字无论写在哪里只要符合规范都 OK,比如 MyReadonly2

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

// 这个是画蛇添足的,因为P肯定是属于keyof T的,就是从keyof T中循环出来的
// 不过证明keyof不仅仅在 <> 中能用
type MyReadonly2<T> = {
  readonly [P in keyof T]: P extends keyof T ?  T[P] : never
}
1
2
3
4
5
6
7
8
9

小拓展: 'name' extends keyof T ? true : false 也能判断 T 这个泛型对象中有没有 name 这个属性

# keyof 和 in 小结

  • keyof 是为了拿到一个对象中的所有的键名,当键名是一个类型的时候,则会全部被升级为对应的 类型
  • in 则是为了循环 union 联合类型数据的,多数都用于循环重新生成一个对象

# JS 转 TS -- typeof 关键字

typeof 作为从 js 世界转换为 ts 世界的内容。

假设一个场景,我们使用一个 obj 对象作为数据映射的存储,比如使用一个 map,存储状态码返回对应的 msg:

const statusMap = {
  200: '操作成功',
  404: '内容找不到',
  500: '操作失败',
  10001: '登录失效'
}

var status1 = 200
console.log(statusMap[status1]) // 操作成功

var status2 = 10001
console.log(statusMap[status2]) // 登录失效
1
2
3
4
5
6
7
8
9
10
11
12

类似上面的场景,那这时候statusMap[] 中间的值肯定只有 200,404,500,10001 才符合要求,在 TS 层面自然我们就要约定 status 在这个范围中

如果不用 typeof ,我们可以会写出这样的 TS:

type Status = 200 | 404 | 500 | 10001

var status1: Status = 200
console.log(statusMap[status1]) // 操作成功

var status2: Status = 10001
console.log(statusMap[status2]) // 登录失效

var status3: Status = 301 // 报红 (Type '301' is not assignable to type 'Status')
1
2
3
4
5
6
7
8
9

这时候假如我们在新增了几个键值,那 TS 还得在同步跟着改一次(太麻烦了),用上 typeof

const statusMap = {
  200: '操作成功',
  404: '内容找不到',
  500: '操作失败',
  10001: '登录失效'
}

type MyStatusMap = typeof statusMap
// 这时候 MyStatus 的值会变成
// type MyStatus = {
//     200: string;
//     404: string;
//     500: string;
//     10001: string;
// }

// 然后接上keyof关键字,提取对象的键名
type MyStatus = keyof MyStatusMap
// type MyStatus = 200 | 404 | 500 | 10001

// 上面的2步可以简写一步到位
type MyStatus2 = keyof typeof statusMap
// type MyStatus2 = 200 | 404 | 500 | 10001
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这样只需要我们改动 JS 的内容,TS 将会自动获取对新的对象。返回对象后还不够,因为我们上面想约束的是传入的键值(想获取键值,刚好上面学习了 keyof 关键字),就能动态获取所有符合规范的键值了!

# typeof 小结

typeof 是一个可以动态把 JS 的对象转换为 TS 的关键字。

不过也有限制场景,那就是转换的前提是这部分 JS 是已固定的内容。就好比例子中的一个对象映射,那是固定的内容,然后让 TS 去推导

而且打包后的代码是不可能存在 TS 的,如果想实现后端接口动态返回内容在用 typeof ,这是实现不了的

# 数组和字符串的循环 推断类型 infer

infer 应用场景非常多

简单一句话概括 infer 只是一个 占位 的工具,我就站在这个位置,至于这个位置是什么内容 infer 并不关心,可以留给后面的程序去判断

用简单题的 00014-easy-first (opens new window) 来讲解一下。 实现一个 First 工具类型

通常获取第一个元素,我们想到的就是 T[0] 当然在 TS 这个语法是可以行得通的

可以在测试用例中有一项

Equal<First<[]>, never> // 使用 T[0] 的话这个会报错,因为 First<[]> 返回的是 undefined
1

也就是说当这个元素非指定声明为 undefined 时,在 TS 多数都是要用 never 代替

正确答案如下:

type First<T extends any[]> = T extends [infer F,...infer Rest] ? F : never
1

简单的说一下

  • infer 必须在 TS 函数运算过程中使用(在定义泛型的<>中不能使用,而 extends 就可以)
  • infer 可以配合 ... 进行运算
  • T extends [infer F,...infer Rest]
    • 表达的意思就是 T extends [F,...Rest 剩余的值]。T 肯定是存在一个属性F的数组,...Rest 是剩下的内容,可有可无
    • ...infer Rest 就是把除了 F 之外的元素在归集为一个数组(这个是 ES6 的知识了)
    • infer F 的意思就是,我拿 F 在这个数组里面 占位 ,数组的第一项的内容,就是被 F 占了
  • 回归到题目,我们要拿的也正是第一项,所以直接 return F 类型
  • 如果 T 是一个空数组,那么 extends 那一步就都判断不通过,自然返回的就是 never,符合测试用例的要求。

# infer 的其他妙用

infer 还能遍历字符串

比如起一个 字符串切割为数组的需求:

type Split<T extends string, U extends unknown[] = []> =
  T extends `${infer F}${infer Rest}` ? Split<Rest, [...U, F]> : U


type testSplit = Split<'123456'>

// type testSplit = ["1", "2", "3", "4", "5", "6"]
1
2
3
4
5
6
7

其中 ${infer F}${infer Rest} 的意思就是,F 占第一个字符,Rest 占剩下的字符,因为在字符串中不存在...的概念,所以 Rest 基本上就是占据了剩下的字符了

像这样的一个测试例子,一共有 3 个占位符,

type testInfer<T extends string> = T extends `${infer F}${infer S}${infer R}` ? [F, S, R] : T

type testInfer1 = testInfer<'123456'>
// 按照占位符的特性,前面F和S分别占据2个字符,剩余的都给R占去了
// type testInfer1 = ["1", "2", "3456"]

// 稍作改动,在S占位符后面添加一个5
type testInfer2<T extends string> = T extends `${infer F}${infer S}5${infer R}` ? [F, S, R] : T
type testInfer3 = testInfer<'123456'>
// F 占第一个字符 = 1
// S 占据2-4,因为在R之前有一个5,所以S代表了第二个字符开始到5的所有字符
// 那么R就是从5开始,到末尾,所以得出的结果如下:
// type testInfer1 = ["1", "234", "6"]
1
2
3
4
5
6
7
8
9
10
11
12
13

后面的习题还有很多会用到 infer ,所以先了解好 infer 占位 的特性就好

# infer 小结

infer 相当于一个占位置的关键字,把占下来的位置复制给对应的运算变量。

其中对于数组或者其他的类型来说,还能用 ... 把所有的位置归结起来形成一个数组

对于字符串这种不存在 ... 拓展运算符的来说,只要前面占了一个位置,剩下的字符就会被第二个占位符全部代替

# 数组的用法

数组也是 TS 体操的一个很重要的特性,因为在 TS 体操中并没有加减法的概念,实际运算中少不了加减法的操作,包括获取长度之类的。

所以数组还充当了 计数器 的作用。关于数组的计数器还有一个非常有用的技巧,有用到我觉得可以单独再起一个文章细细分析,下面就先简单的介绍一下数组的功能

先来一道简单题,学会数组的基本属性和用法 00018-easy-tuple-length (opens new window)

题目给出 2 个数组,用了 as const。需要求出这 2 个数组的长度

const tesla = ['tesla', 'model 3', 'model X', 'model Y'] as const
const spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] as const

type teslaLength = Length<typeof tesla>
type spaceXLength = Length<typeof spaceX>

// 答案:
type Length<T extends readonly any[]> = T['length']
1
2
3
4
5
6
7
8

是不是很简单?! T['length'] 完事~

所以数组一个很重要的特性就是,他有 length 属性。

想用 length 属性的前提: T extends any[] T 类型的范围,肯定是一个数组,any[] 或者 unknown[]。都行,反正是数组,就有 length 属性。


基于这个求长度的问题,延伸一下,求 2 个数组合并后的长度

type MergeArray<T extends readonly any[],U extends readonly any[]> = [...T,...U]['length']

type arrLength = MergeArray<typeof tesla, typeof spaceX> // 9
1
2
3

稍微来点有意思的题目在感受一下数组的作用 难度为中等的题 05153-medium-indexof (opens new window)

实现一个 indexOf(原题还有另外一个需要注意的点就是判断类型的时候需要注意的地方,我后面还会起文章来讲解,现在先看最简单的实现):

既然要算索引位置,自然就涉及到了一个 计数器 的问题,看下面的答案

type numberIndex = IndexOf<[1, 2, 3], 1> // 需要得到的答案是 0
type numberIndex2 = IndexOf<[1, 2, 3], 99> // 需要得到的答案是 -1
type numberIndex3 = IndexOf<[1, 2, 3], 1> // 需要得到的答案是 0

// 题目给出的初始模版(缺少计数器)
// type IndexOf<T extends unknown[],U> = any

// 对于这些缺参数的,我们完全可以自己补一个参数,而且补充默认值
type IndexOf2<T extends unknown[], U, C extends 1[] = []> =         
  T extends [infer F, ...infer Rest] ?             
    (F extends U ? C['length'] : IndexOf<Rest, U, [...C, 1]>) : -1
1
2
3
4
5
6
7
8
9
10
11

讲解部分:

  • 因为题目给出的模版缺少了一个计数器,我们可以补充一个 C 变量,并且默认赋值为 []
    • 只要有默认值,就非必填,非必填的话测试用例就不会报错了
  • T extends 和 infer 部分上面有讲解过,如果 T 已经不满足至少有一个F的时候,说明 T 数组已经空了,空了之后就说明可以得出结果了
    • T 为空了,还没匹配到数据,按 indexOf 的方法应该是返回 -1
    • T 如果符合了至少有一个 F 变量的要求的话,判断F与传入的U数据相比,相同的话返回当前计数器的长度(也就是要计算的索引了) C['length']
    • 不符合的话,拿Rest 数组继续循环(递归调用 IndexOf),与此同时 递归调用的时候 C 的入参变成了 [...C,1] 数组长度递增1
  • [...C,1] 的作用,就是保留原数组的内容,在添加一个 1,使得原数组的 length + 1 计数器 效果达成

通过一个简单的拓展运算符,加上一个 1 使得数组的长度不断的变化。达到计数器/加法运算的效果

比如在第 4182 的题目中,需要实现一个 斐波那契数列。斐波那契数列的特性就是 n = (n-1)+(n-2)。如何实现这个加法? 用代码来说就是 [...(N-1),...(N-2)]。(不理解没关系,后面还有会详细的文章来讲)

# 数组小结

  • 只要对应的类型 extends [] 的话,就可以使用 ['length'] 属性获取长度
  • 数组在体操中不仅仅充当了数组的作用,还充当了 计数器加法实现 的作用,用法千变万化
  • 数组的累加依赖于递归方法的实现,在每一次递归的过程中往新的方法里面传入新的长度(不过递归容易造成内存溢出,比如02257-medium-minusone (opens new window)这一题)。需要一个更加高级的技巧处理

# as 关键字

在上面讲解数组的时候看到有 as const 的出现,那就顺便讲讲 as

在 TS 使用中,as 就是一个 断言

假设这样的一个场景,有一个变量 todo ,设置为了 Todo 类型,有对应的属性

然后某个函数的副作用,导致了我的 todo 变成了一个 string 类型(实际代码应该规避这种副作用函数)

but 事情就这么发生了,而且不能改,这时候的 todo 应该是 string 类型。todo 还需要调用一个方法,需要传入 stirng 类型的 function todoFn(str:string){} 。这时候直接传入 todo 肯定会报错,类型不符合

解决办法就是 todoFn(todo as string)。看下方的代码截图会好理解一点

在我断定了这个类型就是 xxx 类型的时候就能用 as 关键字(当然不推荐使用),尽可能还是用 TS 的类型推导


在 TS 的类型定义的时候,as 又有别的含义

比如说这个

const teslaConst = ['tesla', 'model 3', 'model X', 'model Y'] as const
// const teslaConst: readonly ["tesla", "model 3", "model X", "model Y"]

// 用var变量和const推导是一样的,不过会留下代码隐患,不推荐
var teslaConst2 = ['tesla', 'model 3', 'model X', 'model Y'] as const
// var teslaConst: readonly ["tesla", "model 3", "model X", "model Y"]

const tesla = ['tesla', 'model 3', 'model X', 'model Y']
// const tesla: string[]
1
2
3
4
5
6
7
8
9

区别很明显,as const 的会把所有的值拿出来,而且变成 readonly。因为 const 确实是只读的标记。

不过 TS 不吃 js 变量类型那一套,所以还得通过 as const 来告诉 TS,我断言这个就是一个 const 数组了,里面的元素都不会改了,你可以遍历这里面的值

没用 as const 的只会认为是个 string[]的数组。这是一个很大的区别

# 最后

TS 类型体操前置知识储备大概就介绍了extends,infer,typeof,keyof和in,数组的使用,as关键字

了解了这部分关键字作用之后,完成 TS 体操练习的中等难度的题目不在话下!(起码完成 80%的题目没得问题),剩下的 20% 还需要学习更多的 TS 体操技巧

这种感觉就好像如果你要解开一道一元二次方程之前,你得学习加减乘除的用法和规则。上面介绍的就是加减乘除的入门规则,后面还要学习更加巧妙地技能完成更复杂的 TS 体操

感兴趣的可以到主页看看关于 TS 体操的其他文章

以上的知识点也是我作为一个 TS 小白在摸索完中等题目后总结的一些笔记
如有说错或理解不到位的地方请指出

Last Updated: 1/7/2024, 5:51:59 PM