likes
comments
collection
share

TypeScript类型挑战:学习高级用法

作者站长头像
站长
· 阅读数 67

TypeScript

工具和版本

版本和工具

  • typescript: 4.3.2
  • ts-node: 10.0.0
# 安装typescript
$ npm install -g typescript@4.3.2

# 安装ts-node
$ npm install -g ts-node@10.0.0

知识点

联合类型

联合类型表示多种取值中的一种,采用|来进行分隔:

let value: string | number
value = 123   // 编译正确
value = '456' // 编译正确

value = true  // 编译报错
  • 当不确定为哪种联合类型的时候,只能取其公共属性或者方法。
// 会编译报错:不确定到底是string类型还是number类型
function getLength (value: string | number): number {
  return value.length
}

// 不会编译报错:string和number都有toString()方法
function getString (value: string | number): string {
  return value.toString()
}
  • 当变量确定了其类型,那么TS会自动推导其对应的属性或方法。
let value: string | number
value = '123'
console.log(value.length) // 编译正确

value = 123
console.log(value.length) // 编译报错

字符串字面量

字符串字面量与联合类型的表现形式非常类似,它表示变量只能取某几个字符中的一种。

type EventName = 'click' | 'scroll' | 'mousemove'

function handleEvent (event: EventName) {
  console.log(event)
}

handleEvent('click')    // 编译正确
handleEvent('scroll')   // 编译正确
handleEvent('dbclick')  // 编译错误

元组

一个确定长度且每个位置元素类型也是确定的,这样的数组就可以叫元组。

根据以上定义,我们总结元组如下两个特点:

  • 数组长度不匹配,会报错。
let arr: [string, number]
arr = ['123', 456, 789] // 编译报错
arr = ['123']           // 编译报错

arr = ['123', 456]      // 编译正确
  • 元素类型不匹配,会报错。
let arr: [string, number]
arr = [123, '456'] // 编译报错

arr = ['123', 456] // 编译正确

枚举

枚举类型用来表示取值限定在指定的范围,例如一周只能有七天,颜色只能有红、绿、蓝等。

enum Colors {
  Red = 1,
  Yellow = 2,
  Blue = 3
}

// 正向取值
console.log(Colors.Red)     // 1
console.log(Colors.Yellow)  // 2
console.log(Colors.Blue)    // 3

// 反向取值
console.log(Colors[1])      // Red
console.log(Colors[2])      // Yellow
console.log(Colors[3])      // Blue

函数重载

JavaScript因为其动态语言的特性,并没有真正函数重载的概念,TypeScript中的函数重载,只是对于同一个函数的多种声明(参数数量不同或参数类型不同),当函数开始进行匹配时,会优先从第一个函数声明开始匹配。

function getArea (width: number): number
function getArea (width: number, height?: number): number

function getArea (width: number, height?: number): number {
  if (height) {
    return width * height
  } else {
    return width * width
  }
}

console.log(getArea(10, 20)) // 200
console.log(getArea(10))     // 100

类型守卫(类型断言)

当一个变量为联合类型的时候,TypeScript需要进一步的判断才能推导出它到底属于什么类型,最常见的断言方式有isin这两种。

  • is断言
function isString (str: any): str is string {
  return typeof str === 'string'
}

function getLength (value: string | number): number {
  if (isString(value)) {
    return value.length
  }
  return value.toString().length
}

console.log(getLength('123')) // 3
console.log(getLength(123))   // 3
  • in断言
class Person {
  sayHi () {
    console.log('Hello~')
  }
}
class Animal {
  bark () {
    console.log('汪汪汪')
  }
}

function sound (obj: Person | Animal): void {
  if ('sayHi' in obj) {
    obj.sayHi()
  } else {
    obj.bark()
  }
}

sound(new Person()) // Hello~
sound(new Animal()) // 汪汪汪

泛型

从一个例子认识泛型。

function getValue (obj, key) {
  return obj[key]
}

如果要给以上方法添加TypeScript支持,那么我们需要处理以下三个问题:

  • obj的类型问题。
  • key类型的问题。
  • getValue函数返回值类型的问题。

我们初步改造一下以上方法:

function getValue (obj: any, key: string): any {
  return obj[key]
}

我们发现,在不使用泛型的情况下,以上方法对于类型支持程度十分有限。接下来,我们采用泛型继续改造以上方法。

function getValue<T> (obj: T, key: keyof T): T[keyof T] {
  return obj[key]
}

在改造完毕后,我们发现其中引入了一个新的关键词keyof。其中keyof T表示T类型的键组成的联合类型。

const obj = {
  name: 'AAA',
  age: 23
}
// 'name'|'age'
type keys = keyof typeof obj

TS中的keyof T相当于JavaScript中的Object.keys()

在介绍完keyof后,我们继续完善getValue方法,如下:

function getValue<T, U extends keyof T> (obj: T, key: U): T[U] {
  return obj[key]
}

const obj = {
  name: 'AAA',
  age: 23
}
console.log(getValue(obj, 'name'))  // 编译成功
console.log(getValue(obj, 'age'))   // 编译成功

console.log(getValue(obj, 'sex'))   // 编译失败, 'sex'属性不存在

代码详解:

  • U extends keyof T:这段代码表示第二个泛型的取值限定在T类型的键名范围,对应到实际函数调用则表示:函数第二个参数的取值,只能是obj对象中的某一个键名。
  • T[U]:此代码表示取T类型实际键名的定义类型作为返回值,对应到实际函数返回值上则表示:当第二个参数传递name时,getValue()函数返回值为string;当第二个参数传递age时,getValue()函数返回值为number
  • 类型推断:虽然我们给getValue()方法定义了两个泛型TU,但是我们在真正函数调用的时候并没有写泛型,TS会根据实际传参自动推导其泛型。
// 自动推导
getValue<{name: string;age: number},'name'>(obj, 'name')
getValue<{name: string;age: number},'age'>(obj, 'age')

extends

在介绍泛型这一小节,我们引出了一个extends关键词,对于extends关键词的用法,一般有两种:类型约束和条件类型。

类型约束

类型约束经常和泛型一起使用,拿泛型小节中的例子来说明:

// 类型约束
U extends keyof T

keyof T是一个整体,它表示一个联合类型。U extends Union这一整段表示U的类型被收缩在一个联合类型的范围内。

这样做的实际表现为:第二个参数传递的字符串只能是T键名中的一个,传递不存在的键名会报错。

条件类型

常见的条件类型表现形式如下:

T extends U ? 'Y' : 'N'

我们发现条件类型有点像JavaScript中的三元表达式,事实上它们的工作原理是类似的,例如:

type res1 = true extends boolean ? true : false // true
type res2 = 'name' extends 'name'|'age' ? true : false // true
type res3 = [1, 2, 3] extends { length: number; } ? true : false // true
type res4 = [1, 2, 3] extends Array<number> ? true : false // true

在条件类型中,有一个特别需要注意的东西就是:分布式条件类型,如下:

// 内置工具:交集
type Extract<T, U> = T extends U ? T : never;

type type1 = 'name'|'age'
type type2 = 'name'|'address'|'sex'

// 结果:'name'
type test = Extract<type1, type2>

// 推理步骤
'name'|'age' extends 'name'|'address'|'sex' ? 'name'|'age' : never
=> ('name' extends 'name'|'address'|'sex' ? 'name' : never) |
   ('age' extends 'name'|'address'|'sex' ? 'age' : never)
=> 'name' | never
=> 'name'

代码详解:

  • T extends U ? T : never:因为T是一个联合类型,所以这里适用于分布式条件类型的概念。根据其概念,在实际的过程中会把T类型中的每一个子类型进行迭代,如下:
// 第一次迭代:
'name' extends 'name'|'address'|'sex' ? 'name' : never
// 第二次迭代:
'age' extends 'name'|'address'|'sex' ? 'age' : never
  • 在迭代完成之后,会把每次迭代的结果组合成一个新的联合类型(剔除never),如下:
type result = 'name' | never => 'name'

infer关键词

介绍

infer关键词的作用是延时推导,它会在类型未推导时进行占位,等到真正推导成功后,它能准确的返回正确的类型。

为了更好的理解infer关键词的用法,我们使用ReturnTypePromiseType这两个例子来说明。

ReturnType

ReturnType是一个用来获取函数返回类型的工具。

type ReturnType<T> = T extends (...args: any) => infer R ? R : never
type UserInfo = {
  name: string;
  age: number;
}
function login(username: string, password: string): UserInfo {
  const userInfo = { name: 'admin', age: 99 }
  return userInfo
}

// 结果:{ name: string; age: number; }
type result = MyReturnType<typeof login>

代码详解:

  • T extends (...args: any) => infer R:如果不看infer R,这段代码实际表示:T是不是一个函数类型。
  • (...args: any) => infer R:这段代码实际表示:一个函数,我们把它的参数使用args来表示,把它的返回类型用R来进行占位。
  • 如果T满足是一个函数类型,那么我们返回其函数返回类型,也就是R,如果不是一个函数类型,我们返回never

PromiseType

PromiseType是一个用来获取Promise包裹的类型的工具。

type MyPromiseType<T> = T extends Promise<infer R> ? R : never

// 结果:string[]
type result = MyPromiseType<Promise<string[]>>

代码详解:

  • T extends Promise<infer R>:这段代码实际表示:T是不是一个Promise包裹类型。
  • Promise<infer R>:这段代码实际表示:Promise里面实际包裹的类型,我们使用R来占位,例如:
Promise<string> => R = string
Promise<number> => R = number 
  • 如果T满足是一个Promise包裹类型,那么返回其包裹的类型R,否则返回never

挑战题

上面的基础知识部分,我们主要参考其他TypeScript文章或者官网知识来进行讲解。

在挑战题部分,我们更偏向于使用上面的知识来解决实际的问题,这部分主要参考Type-Challenges,我们将这些挑战题按类型进行划分,主要分为:

  • 内置工具类
  • 内置工具类扩展
  • 数组类
  • 字符串类
  • 递归类
  • 实际场景题类

其中对于每一类,都可能包含简单一般以及困难等三个难度。

内置工具类

内置工具类,是指那些由TypeScript官方默认提供的工具函数,你可以在lib.es5.d.ts中找到它们的原生实现。

Required(必填)和Partial(可填)

Required是用来将所有字段变成必填的,而Partial所做的事情和它相反,它是用来将所有字段变成可填的。

type MyPartial<T> = {
  [P in keyof T]?: T[P]
}
type MyRequired<T> = {
  [P in keyof T]-?: T[P]
}
type Person = {
  name: string;
  age?: number;
}

// 结果:{ name: string; age: number; }
type result1 = MyRequired<Person>
// 结果:{ name?: string; age?: number; }
type result2 = MyPartial<Person>

代码详解:

  • keyof T:这段代码是获取T类型中的所有键,所有键组合成一个联合类型,例如:'name'|'age'
  • P in keyof TP in属于一个迭代过程,可以用JavaScript中的for in迭代来理解,例如:
P in 'name'|'age'
// 第一次迭代:P = 'name'
// 第二次迭代:P = 'age'
  • T[P]:属于一个正常的取值操作,在TypeScript中,不能通过T.P的形式取值,而应该用T[P]
  • -?:这段代码可以从语义上直观的理解成:去掉?这个符号。

Readonly(只读)和Mutalbe(可写)

Readonly是用来将所有字段变成只读的,Mutable所做的事情跟它相反,它是用来将所有字段变成可写的。

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}
type MyMutable<T> = {
  -readonly [P in keyof T]: T[P]
}
type Person = {
  name: string;
  readonly age?: number;
}

// 结果:{ readonly name: string; readonly age?: number; }
type result1 = MyReadonly<Person>
// 结果:{ name: string; age?: number; }
type result2 = MyMutable<Person>

代码详解:

  • 这里出现的keyofin,我们在上面的例子中已经详细进行了描述,这里不再赘述。
  • -readonly:这段代码表示把readonly关键词去掉,去掉之后就从只读变成了可写。

Record(构造)

Record的作用有点像JavaScript中的map方法,它是用来将K的每一个键(k)指定为T类型,这样由多个k/T组合成了一个新的类型。

type MyRecord<k extends keyof any, T> = {
  [P in K]: T
}
type pageList = 'login' | 'home'
type PageInfo = {
  title: string;
  url: string;
}
type Expected = {
  login: { title: string; url: string; };
  home: { title: string; url: string; };
}
// 结果:Expected
type result = MyRecord<pageList, PageInfo>

代码详解:

  • k extends keyof any:此代码表示Kkeyof any任意类型其所有键的子类型,例如:
// K为 ‘Dog’|'cat'
type UnionKeys = 'Dog' | 'Cat'

// K为'name'|'age'
type Person = {
  name: string;
  age: number;
}
type TypeKeys = keyof Person

Pick(选取)

Pick的作用是从指定的类型中选取指定的字段组成一个新的类型。

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}
type Person = {
  name: string;
  age: number;
  address: string;
}

// 结果:{ age: number; address: string; }
type result = MyPick<Person, 'age' | 'address'>
  • K extends keyof T:表示K只能是keyof T的子类型,如果我们在使用Pick的时候传递了不存在于T的字段,会报错:
// 报错:phone无法分配给keyof T
type result = MyPick<Person, 'name' | 'phone'>

Exclude(排除)

Exclude的作用是从T中移除那些存在于U中的类型,既:取差集。

type MyExclude<T, U> = T extends U ? never : T
// 结果:'name'
type result = MyExclude<'name'|'age'|'sex', 'age'|'sex'>

代码详解:我们之前在extends章节分析过一个类似的问题,只不过之前分析的是求交集,这里是求差集。但原理是类似的,都是属于分布式条件类型的概念。

T extends U ? never ? T
// 第一次迭代分发:
'name' extends 'age' | 'sex' ? never : 'name' => 'name'
// 第二次迭代分发:
'age' extends 'age' | 'sex' ? never : 'age' => never
// 第三次迭代分发:
'sex' extends 'age' | 'sex' ? never : 'sex' => never
// 结果:
type result = 'name' | never | never => 'name'

Omit(剔除)

Omit所做的事情和Pick相反,Omit指的是从指定的T类型中移除指定的字段,剩下的字段组成一个新类型。

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}
type MyExclude<T, U> = T extends U ? never : T
type MyOmit<T, K> = MyPick<T, MyExclude<keyof T, K>>
type Person = {
  name: string;
  age: number;
  address: string;
}

// 结果:{ name: string;age:number; }
type result = MyOmit<Person, 'address'>

代码详解:

  • 使用MyExclude<keyof T, K>,我们能从T中移除指定的字段,得到一个联合类型,例如:'name'|'age'
  • 使用MyPick<T, 'name'|'age'>,我们可以从T中选取这两个字段组合成一个新的类型。

内置工具类扩展

内置工具类扩展这小节,我们结合之前的RequiredReadonly这两个内置工具,扩展出RequiredKeysOptionalKeysGetRequiredGetOptional这几个工具方法。

RequiredKeys(所有必填字段)

RequiredKeys是用来获取所有必填字段,其中这些必填字段组合成一个联合类型。

type RequiredKeys<T> = {
  [P in keyof T]: T extends Record<P,T[P]> ? P : never
}[keyof T]
type Person = {
  name: string;
  age?: number;
  address?: string;
}

// 结果:'name'
type result = RequiredKeys<Person>

RequiredKeys实现思路:

  • 第一步:将key/value构造成key/key的形式:
type RequiredKeys<T> = {
  [P in keyof T]: P
}
type Person = {
  name: 'name';
  age?: 'age';
  address?: 'address';
}
  • 第二步:T[keyof T]取值得到一个联合类型:
type RequiredKeys<T> = {
  [P in keyof T]: P
}[keyof T]
type Person = {
  name: 'name';
  age?: 'age';
  address?: 'address';
}
// 'name'|'age'|'address'
type keys = RequiredKeys<Person>
  • 第三步:理解TS中的类型关系,类型具体的是子类,类型宽泛的是父类。
// 结果: true
type result1 = Person extends { name: string; } ? true : false
// 结果:false
type result2 = Person extends { age?: number; } ? true : false

依据以上的知识点,我们用如下一行代码来表示这种关系:

T extends Record<P, T[P]> ? P : never

再将以上例子带入,结果为:

Person extends Record<'name', string> ? 'name' : never // 'name'
Person extends Record<'age', number> ? 'age' : never // never
  • 第四步:完整实现
type RequiredKeys<T> = {
  [P in keyof T]: T extends Record<P,T[P]> ? P : never
}[keyof T]

OptionalKeys(所有可选字段)

type OptionalKeys<T> = {
  [P in keyof T]: {} extends Pick<T, P> ? P : never
}[keyof T]
type Person = {
  name: string;
  age?: number;
  address?: string;
}

// 结果:'age'|'address'
type result = OptionalKeys<Person>

实现思路:

  • 第一步:将key/value构造成key/key的形式:
type OptionalKeys<T> = {
  [P in keyof T]: P
}
type Person = {
  name: 'name';
  age?: 'age';
  address?: 'address';
}
  • 第二步:T[keyof T]取值得到一个联合类型:
type OptionalKeys<T> = {
  [P in keyof T]: P
}[keyof T]
type Person = {
  name: 'name';
  age?: 'age';
  address?: 'address';
}
// 'name'|'age'|'address'
type keys = OptionalKeys<Person>
  • 第三步:理解TS中的类型关系,类型具体的是子类,类型宽泛的是父类。
// 结果: true
type result = {} extends { age?: string; } ? true : false

你可能对{} extends { age?: string; }这段代码为true并不是很理解,那么我们换一种思路来理解:

type result = {} extends {} | {age: string;} ? true : false

根据以上案例,我们使用一行代码来表示这种关系:

{} extends Pick<T, P> ? P : never

带入我们的例子,结果如下:

{} extends Pick<Person, 'name'> ? 'name' : never => 'name'
{} extends Pick<Person, 'age'> ? 'age' : never => never
{} extends Pick<Person, 'address'> ? 'address' : never => never
  • 完整实现
type OptionalKeys<T> = {
  [P in keyof T]: {} extends Pick<T, P> ? P : never
}[keyof T]

GetRequired(所有必填类型)

GetRequired是用来获取一个类型中,所有必填键及其类型所组成的一个新类型的。

在实现了RequiredKeys的基础上,我们很容易实现GetRequired

type GetRequired<T> = {
  [P in RequiredKeys<T>]-?: T[P]
}
type Person = {
  name: string;
  age?: number;
  address?: string;
}
// 结果: { name: string; }
type result = GetRequired<Person>

GetOptional(所有可选类型)

GetOptional是用来获取一个类型中,所有可选键及其类型所组成的一个新类型的。

在实现了OptionalKeys的基础上,我们很容易实现GetOptional

type GetOptional<T> = {
  [P in OptionalKeys<T>]?: T[P]
}
type Person = {
  name: string;
  age?: number;
  address?: string;
}
// 结果: { age?: number; address?: string; }
type result = GetOptional<Person>

数组类

匹配数组时,经常会使用到infer关键词,通常有如下几种常见的用法:

// 匹配空数组
T extends [] ? true : false

// 数组前匹配
T extends [infer L, ...infer R] ? L : never

// 数组后匹配
T extends [...infer L, infer R] ? R: never;

FirstOfArray(数组第一个元素)

利用数组前匹配的思想,可以很容易的实现获取数组第一个元素FirstOfArray这个工具。

type FirstOfArray<T extends any[]> = T extends [infer L, ...infer R] ? L : never
// 结果:1
type result = FirstOfArray<[1, 2, 3]>

代码详解:

  • T extends any[]:限定T类型必须是一个数组类型。
  • T extends [infer L, ...infer R]T是不是一个以L为第一个元素,其余元素以R占位的数组形式,其中R可以是空数组,下面这几种形式都满足上述条件。
// L = 1, R = [2, 3, 4]
const arr1 = [1, 2, 3, 4]
// L = 1, R = []
const arr2 = [1]

LastOfArray(数组最后一个元素)

利用数组后匹配的思想,可以很容易的实现获取数组最后一个元素LastOfArray这个工具。

type LastOfArray<T extends any[]> = T extends [...infer L, infer R] ? R : never
// 结果:3
type result = Last<[1, 2, 3]>

代码详解:

  • T extends [...infer L, infer R]T是不是一个以R为最后一个元素,其余元素以L占位的数组形式,其中L可以是空数组,下面这几种形式都满足上述条件。
// L = [1, 2, 3], R = 4
const arr1 = [1, 2, 3, 4]
// L = [] R = 1
const arr2 = [1]

ArrayLength(数组长度)

要获取数组的长度,可以直接使用T['length']的方式进行获取。

注意:这里不能使用T.length的形式进行取值。

type ArrayLength<T extends readonly any[]> =  T['length']
// 结果:3
type result = ArrayLength<[1, 2, 3]>

扩展:在上面的实现方案中,我们只能传递一个一般数组,如果想兼容传递类数组,则需要改动代码,如下:

type ArrayLength<T> = T extends { length: number } ? T['length'] : never

type result1 = ArrayLength<[1, 2, 3]> // 3
type result2 = ArrayLength<{ 0: '0', length: 12 }> // 12

Concat(数组concat方法)

根据数组concat的用法,我们只需要把两个数组,使用展开运算符...,展开到一个新数组中即可。

type MyConcat<T extends any[], U extends any[]> = [...T, ...U]

// 结果:[1, 2, 3, 4]
type result = MyConcat<[1, 2], [3, 4]>

代码详解:

  • T extends any[]:限定T必须是一个数组类型,U也是如此。
  • [...T, ...U]:这个TU代表都代表数组类型,因此可以使用展开运算符展开数组。

Includes(数组includes方法)

TS中,一个数组中的所有元素,可以使用T[number]来表示,它是一个联合类型,由所有元素共同组成。

type MyIncludes<T extends any[], K> = K extends T[number] ? true : false

type result1 = MyIncludes<[1, 2, 3, 4], '4'> // false
type result2 = MyIncludes<[1, 2, 3, 4], 4> // true

代码详解:

  • T[number]:表示由数组所有元素公共组成的一个联合类型,例如:1 | 2 | 3 | 4

Push和Pop(数组Push和Pop方法)

Push方法很好实现,对于Pop方法,我们需要使用数组后匹配的思想来做。

type MyPush<T extends any[], K> = [...T, K]
type MyPop<T extends any[]> = T extends [...infer L, infer R] ? L : never

type result1 = MyPush<[1, 2, 3], 4> // [1, 2, 3, 4]
type result2 = MyPush<[1, 2, 3], [4, 5]> // [1, 2, 3, [4, 5]]
type result3 = MyPop<[1, 2, 3]> // [1, 2]
type result4 = MyPop<[1]> // []

字符串类

匹配字符串与匹配数组,都会用到infer关键词,但是它们的表现形式不太相同,例如:

S extends `${infer S1}${infer S2}` ? S1 : never
T extends [infer L, ...infer R] ? L : never

StringLength(字符串长度)

实现思路:运用递归、infer占位和辅助数组的思想来实现。

type StringLength<S extends string, T extends any[] = []> = 
  S extends `${infer S1}${infer S2}`
    ? LengthOfString<S2, [...T, S1]>
    : T['length']

type result1 = StringLength<''> // 0
type result2 = StringLength<'123'> // 3
type result3 = StringLength<' 1 2 3 '> // 7

我们以上面第二个例子为例,进行详细说明:

type result2 = StringLength<'123'> // 3

// 第一次递归调用,满足S extends `${infer S1}${infer S2}`
S = '123' S1 = '1' S2 = '23' T = []

// 第二次递归调用,满足S extends `${infer S1}${infer S2}`
S = '23' S1 = '2' S2 = '3' T = ['1']

// 第三次递归调用,满足S extends `${infer S1}${infer S2}`
S = '3' S1 = '3' S2 = '' T = ['1', '2']

// 第四次递归调用,不满足S extends `${infer S1}${infer S2}`
S = '' S1 = '' S2 = '' T = ['1', '2', '3']

// 结果:3
type result = T['length']

Capitalize(首字母大写)

Capitalize是用来将字符串首字母大写,会使用到内置的Uppercase这个工具。

type MyCapitalize<S extends string> =
  S extends `${infer S1}${infer S2}`
    ? `${Uppercase<S1>}${S2}`
    : S

// 结果:Abc
type result = MyCapitalize<'abc'>

代码详解:字符串'abc'满足${infer S1}${infer S2}这种格式,此时S1 = 'a'S2 = 'bc',再将S1S2拼接起来,其中S1使用Uppercase内置工具转换成大写形式,变成A,所以结果为:Abc

扩展:根据以上思路,我们可以写一个MyUnCapitalize工具,它所做的事情和MyCapitalize相反,实现代码如下:

type MyUnCapitalize<S extends string> =
  S extends `${infer S1}${infer S2}`
    ? `${Lowercase<S1>}${S2}`
    : S

StringToArray(转数组)

借用StringLength的实现思路,我们很容易能实现StringToArray这个工具。

type StringToArray<S extends string, T extends any[] = []> = 
    S extends `${infer S1}${infer S2}`
      ? StringToArray<S2, [...T, S1]>
      : T

// 结果:['a', 'b', 'c']
type result = StringToArray<'abc'>

StringToUnion(转联合)

运用递归的思想,我们也能很快的实现StringToUnion这个工具,它的作用是将字符串转成一个联合类型。

type StringToUnion<S extends string> = 
  S extends `${infer S1}${infer S2}`
    ? S1 | StringToUnion<S2>
    : never

// 结果:'a'|'b'|'c'
type result = StringToUnion<'abc'>

CamelCase(连字符转小驼峰)

CamelCase是用来将连字符的字符串,转换成小驼峰的形式的工具。

type CamelCase<S extends string> =
  S extends `${infer S1}-${infer S2}`
    ? S2 extends Capitalize<S2>
      ? `${S1}-${CamelCase<S2>}`
      : `${S1}${CamelCase<Capitalize<S2>>}`
    : S

// 结果:'fooBarBaz'
type result = CamelCase<'foo-bar-baz'>

代码详解:CamelCase的实现,同样使用到了递归的思路,我们以上面例子为例进行详细的说明:

type result = CamelCase<'foo-bar-baz'>

// 第一次递归调用 S满足${infer S1}-${infer S2} S2不满足extends Capitalize<S2>
S = 'foo-bar-baz' S1 = 'foo' S2 = 'bar-baz'

// 第二次递归调用 S满足${infer S1}-${infer S2} S2不满足extends Capitalize<S2>
S = 'Bar-baz' S1 = 'Bar' S2 = 'baz'

// 第三次递归调用 S不满足${infer S1}-${infer S2}
S = 'Baz'

// 结果:fooBarBaz
type result = 'foo' + 'Bar' + 'Baz' => 'fooBarBaz'

Get(属性路径取值)

你可能听说过,或者使用过lodash工具库的get方法,它支持对一个对象进行字符串路径的形式进行取值,例如:

import _ from 'lodash'

const obj = {
  foo: {
    bar: {
      value: 'foobar',
      count: 6,
    },
    included: true,
  },
  hello: 'world'
}

console.log(_.get(obj, 'foo.bar.value')) // 'foobar'

我们同样可以在TS中,实现此功能,既Get工具。

type Get<T, K extends string> =
  K extends `${infer S1}.${infer S2}`
    ? S1 extends keyof T
      ? Get<T[S1], S2>
      : never
    : K extends keyof T
      ? T[K]
      : never
type Data = {
  foo: {
    bar: {
      value: 'foobar',
      count: 6,
    },
    included: true,
  },
  hello: 'world'
}

type result1 = Get<Data, 'hello'> // 'world'
type result2 = Get<Data, 'foo.bar.value'> // 'foobar'
type result3 = Get<Data, 'baz'> // never

代码详解:

  • 对第一个例子而言,它不满足${infer S1}.${infer S2}的形式,但是满足keyof T,此时我们只需要简单的根据键名取值即可,因此结果为'world'
  • 对第二个例子而言,它满足${infer S1}.${infer S2}的形式,此时S1='foo'S1又满足keyof T的形式,所以存在递归调用,我们以下面代码来详细拆解递归的过程。
// 第一次递归调用,K满足`${infer S1}.${infer S2},S1满足keyof T
T = Data K = 'foo.bar.value' S1 = 'foo' S2 = 'bar.value'

// 第二次递归调用,K满足`${infer S1}.${infer S2},S1满足keyof T
T = Data['foo'] K = 'bar.value' S1 = 'bar' S2 = 'value'

// 第三次递归调用,K不满足`${infer S1}.${infer S2},K满足keyof T
T = Data['foo']['bar'] K = 'value'

// 结果: 'foobar'
type result = Data['foo']['bar']['value']

StringToNumber(转数字)

StringToNumber是用来将字符串类型的数字,转换成真正数字类型数字的工具。

JavaScript中,我们可以很方便的调用Number()方法或者parseInt()方法来将字符串类型的数字,转换成数字类型的数字。但在TS中,并没有这样的方法,需要我们来手动实现。

// 结果:123
type result = StringToNumber<'123'>

StringToNumber的实现并不容易理解,我们需要将其进行拆分,一步步来完善,其实现思路如下:

  • 第一步:可以很容易获取字符串'123'中每一位字符,我们将其存储在辅助数组T中,如下:
type StringToNumber<S extends string, T extends any[] = []> = 
  S extends `${infer S1}${infer S2}`
    ? StringToNumber<S2, [...T, S1]>
    : T

// 结果:['1', '2', '3']
type result = StringToNumber<'123'>
  • 第二步:我们需要将单个字符串类型的数字,转换成真正数字类型的数字,可以借助中间数组来帮忙,例如:
'1' => [0]['length'] => 1
'2' => [0,0]['length'] => 2
'3' => [0,0,0]['length'] = 3
...
'9' => [0,0,0,0,0,0,0,0,0]['length'] => 9

根据以上规律,我们封装一个MakeArray方法,它的实现代码如下:

type MakeArray<N extends string, T extends any[] = []> =
  N extends `${T['length']}`
  ? T
  : MakeArray<N, [...T, 0]>

type t1 = MakeArray<'1'> // [0]
type t2 = MakeArray<'2'> // [0, 0]
type t3 = MakeArray<'3'> // [0, 0, 0]
  • 第三步:现在有了百位,十位和个位的数字,我们应该运用算术把它们按照一定的规律累加起来,如下:
const arr = [1, 2, 3]
let target = 0

// 第一次迭代
target = 10 * 0 + 1 = 1
// 第二次迭代
target = 10 * 1 + 2 = 12
// 第三次迭代
target = 10 * 12 + 3 = 123

根据以上思路,我们还需要一个乘十的工具函数,对应到实际需求,就是需要把一个数组copy十次,因此我们封装一个Multiply10工具,其实现代码如下:

type Multiply10<T extends any[]> = [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T]

type result = Multiply10<[1]> // [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
  • 第四步:根据前几步的分析,把所有东西串联起来,StringToNumber完整实现代码如下:
type Digital = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type Multiply10<T extends any[]> = [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T]
type MakeArray<N extends string, T extends any[] = []> =
  N extends `${T['length']}`
  ? T
  : MakeArray<N, [...T, 0]>

type StringToNumber<S extends string, T extends any[] = []> = 
  S extends `${infer S1}${infer S2}`
    ? S1 extends Digital
      ? StringToNumber<S2, [...Multiply10<T>, ...MakeArray<S1>]>
      : never
    : T['length']
  • 第五步:为了更好的理解递归的过程,我们拆解成如下步骤来说明:
type result = StringToNumber<'123'>

// 第一次递归,S满足${infer S1}${infer S2}, S1满足Digital
S = '123' S1 = '1' S2 = '23' T = [0] T['length'] = 1

// 第二次递归,S满足${infer S1}${infer S2}, S1满足Digital
S = '23'  S1 = '2' S2 = '3' T = [0,....0] T['length'] = 10

// 第三次递归,S满足${infer S1}${infer S2}, S1满足Digital
S = '3'  S1 = '3' S2 = '' T = [0,....0] T['length'] = 120

// 第四次递归,S不满足${infer S1}${infer S2} T['length']取值
S = '' T = [0,....0] T['length'] = 123

// 结果:
type result = StringToNumber<'123'> // 123

递归类

尽管在之前的挑战题中,我们已经用到了递归,但依然需要特地介绍一下。我们选取具有代表性的DeepReadonlyDeepPick来进行说明。

DeepReadonly(深度Readonly)

DeepReadonly是用来将一个类型中所有字段都变成只读的一个工具,目前我们只需要考虑嵌套对象。

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends { [key: string]: any }
                                ? DeepReadonly<T[P]>
                                : T[P]
}
type Person = {
  name: string;
  age: number;
  job: {
    name: string;
    salary: number;
  }
}
type Expected = {
  readonly name: string;
  readonly age: number;
  readonly job: {
    readonly name: string;
    readonly salary: number;
  }
}

// 结果:Expected
type result = DeepReadonly<Person>

代码详解:在DeepReadonly的实现中,我们使用T[P] extends { [key: string]: any }来判断当前值是不是一个对象,是使用索引签名来匹配的。如果满足是一个对象,那么再递归的调用DeepReadonly

// 第一次遍历,P = 'name' T[P]不是一个对象
// 第二次遍历,P = 'age' T[P]不是一个对象
// 第三次遍历,P = 'job' T[P]是一个对象

// 递归第一次遍历,P = 'name' T[P]不是一个对象
// 递归第二次遍历,P = 'salary' T[P]不是一个对象

DeepPick(深度Pick)

DeepPick是用来深度取值的一个工具,如下:

type Obj = {
  a: number,
  b: string,
  c:  boolean,
  obj: {
    d: number,
    e: string,
    f:  boolean,
    obj2: {
      g: number,
      h: string,
      i: boolean,
    }
  },
  obj3: {
    j: number,
    k: string,
    l: boolean,
  }
}
type Expected = {
  obj: {
    obj2: {
      g: number
    }
  }
}

// 结果:Expected
type result = DeepPick<Obj, 'obj.obj2.g'>

你可能会对这种取值方式十分熟悉,没错,它的实现思路我们之前实现的Get属性路径取值十分相似,不过需要做一点小小的改动,完整代码如下:

// 和DeepPick做对比
type Get<T, K extends string> =
  K extends `${infer S1}.${infer S2}`
    ? S1 extends keyof T
      ? Get<T[S1], S2>
      : never
    : K extends keyof T
      ? T[K]
      : never

type DeepPick<T, S extends string> = 
  S extends `${infer S1}.${infer S2}`
    ? S1 extends keyof T
      ? { [K in S1]: Get<T[S1], S2> }
      : never
    : S extends keyof T
      ? { [K in S]: T[K] }
      : never

场景题

Join方法

Join方法有点类似数组的join方法,但又有点不一样,具体测试案例如下:

// 案例一:无参数,返回空字符串
join('-')() // ''
// 案例二:只有一个参数,直接返回
join('-')('a') // 'a'
// 案例三:有多个参数,以分隔符分割
join('')('a', 'b') // 'ab'
join('#')('a', 'b') // 'a#b'

我们需要实现一个这样的Join方法,它的原型如下:

declare function join(delimiter: any): (...parts: any[]) => any

实现思路:

  • 第一步:为了能够获取到delimiterparts,我们引入两个泛型。
declare function join<D extends string>(delimiter: D): <P extends string[]>(...parts: P) => any
  • 第二步:为了能够处理Join函数的返回值,我们定义一个JoinType来表示。
type JoinType<D extends string, P extends string[]> = any
declare function join<D extends string>(delimiter: D): <P extends string[]>(...parts: P) => JoinType<D, P>
  • 第三步:根据之前撰写的案例,完善JoinType工具函数。
type Shift<T extends any[]> = T extends [infer L, ...infer R] ? R : []
type JoinType<D extends string, P extends string[]> = 
  P extends []
    ? ''
    : P extends [infer Head]
      ? Head
      : `${P[0]}${D}${JoinType<D, Shift<P>>}`

JoinType实现详解:

  • Shift:需要实现一个类似数组的shift的方法,它的作用是从数组头部移除元素,使用的是数组前匹配的思想。
  • P extends []:这里判断P是否为一个空数组,覆盖案例一。
  • P extends [infer Head]:这里判断p是否是只有一个元素的数组,覆盖案例二。
  • ${P[0]}${D}${JoinType<D, Shift<P>>}:这里运用到了递归的思想,主要流程就是把P中的元素一个一个使用分隔符D链接起来。
// 案例三: D = '#', P = ['a', 'b']
// 第一次迭代,P不为空数组且是多元素数组。
P = ['a', 'b'] result = `a#`
// 第二次迭代,P不为空数组,但是只有一个元素。
P = ['b'] result = `a#b`
// 结果
type result = Join('#')('a', 'b') // 'a#b'

Chainable链式调用

在日常的开发过程中,会经常使用到链式调用。现有一个对象,它有optionsget这两个方法,其中option方法用来添加新属性,get方法用来获取最新的对象,使用案例如下:

// 结果:{ foo: 123, bar: { value: 'Hello' }, name: 'TypeScript' }
const result = obj
  .option('foo', 123)
  .option('bar', { value: 'Hello' })
  .option('name', 'TypeScript')
  .get()

现要实现一个Chainable工具函数,来支持对象的这种行为,实现思路如下:

  • 第一步:Chainable首先要满足对象中存在optionget这两个方法。
type Chainable<T> = {
  option(key: any, value:any): any
  get(): any
}
  • 第二步:处理get函数的返回类型
type Chainable<T> = {
  option(key: any, value:any): any
  get(): T
}
  • 第三步:处理option函数的参数类型及其返回类型。
type Chainable<T = {}> = {
  options<K extends string, V>(key: K, value:V): Chainable<T & { [P in K]: V }>
  get(): { [P in keyof T]: T[P] }
}

在这一步中,出现了一个新的知识点:交叉类型,可以使用JavaScript中的对象Merge来理解。

注意:当两个联合类型使用&进行交叉式,其表现方式有点不用,它是取交集。

// 结果:{ name: string; age: number; address: string; }
type result1 = { name: string; age: number; } & { address: string; }

// 结果:'age'
type result2 = ('name' | 'age') & ('age' | 'address')
  • 第四步:测试。
type Expected = {
  foo: number
  bar: {
    value: string
  }
  name: string
}
declare const obj: Chainable<Expected>

// test
const result = obj
  .options('foo', 123)
  .options('bar', { value: 'Hello' })
  .options('name', 'TypeScript')
  .get()

参考

转载自:https://juejin.cn/post/6980158674681462820
评论
请登录