Skip to content
目录

类型编程主要的目的就是对类型做各种转换,那么如何对类型做修改呢?

TypeScript 类型系统支持 3 种可以声明任意类型的变量:

  1. type
  2. infer
  3. 类型参数

1️⃣ type 叫做类型别名,其实就是声明一个变量存储某个类型:

typescript
type ttt = Promise<number>

2️⃣ infer 用于类型的提取,然后存到一个变量里,相当于局部变量

typescript
type GetValueType<P> = P extends Promise<infer Value> ? Value : never

3️⃣ 类型参数 用于接受具体的类型,在类型运算中也相当于局部变量

typescript
type isTwo<T> = T extends 2 ? true : false

但是,严格来说这三种也都不叫变量,因为它们不能被重新赋值😅。

TypeScript 设计可以做类型编程的类型系统的目的就是为了产生各种复杂的类型,那不能修改怎么产生新类型呢?

答案是重新构造

这就涉及到了第二个类型体操套路:重新构造做变换🎉。

重新构造

TypeScript 的 type、infer、类型参数声明的变量都不能修改,想对类型做各种变换产生新的类型就需要重新构造。

数组、字符串、函数等类型的重新构造比较简单。

索引类型,也就是多个元素的聚合类型的重新构造复杂一些,涉及到了映射类型的语法。

我们先从简单的开始。

数组类型的重新构造

Push

有这样一个元组类型:

typescript
type tuple = [1,2,3];

我想给这个元组类型再添加一些类型,怎么做呢?

TypeScript 类型变量不支持修改,我们可以构造一个新的元组类型:

typescript
type Push<Arr extends unknown[], Ele> = [...Arr, Ele]
  • 类型参数 Arr 是要修改的数组/元组类型,元素的类型任意,也就是 unknown。

  • 类型参数 Ele 是添加的元素的类型。

  • 返回的是用 Arr 已有的元素加上 Ele 构造的新的元组类型

Array Push

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
/**
* Arr 是一个数组类型
* Elm 是任意类型
* 将元素添加到数组后面
*/
function Push(Arr is unknown[], Elm) {
  return [...Arr, Elm]
}

这就是数组/元组的重新构造。

💡

数组和元组的区别:数组类型是指任意多个同一类型的元素构成的,比如 number[]Array<number>,而元组则是数量固定,类型可以不同的元素构成的,比如 [1, true, 'guang']。

Unshift

可以在后面添加,同样也可以在前面添加:

typescript
type Unshift<Arr extends unknown[], Elm> = [Elm, ...Arr]

Array Unshift

这两个案例比较简单,我们来做一个复杂的.

⚡ Zip

有这样两个元组:

typescript
type tuple1 = [1,2];
type tuple2 = ['guang', 'dong'];

我们想把它们合并成这样的元组:

typescript
type tuple = [[1, 'guang'], [2, 'dong']];

思路很容易想到,提取元组中的两个元素,构造成新的元组:

typescript
type Zip<One extends [unknown, unknown], Two extends [unknown, unknown]> =
	One extends [infer OneFirst, infer OneSecond]
		? Two extends [infer TwoFirst, infer TwoSecond]
			? [[OneFirst, TwoFirst], [OneSecond, TwoSecond]]
			: []
		: []
  • 两个类型参数 One、Two 是两个元组,类型是 [unknown, unknown],代表 2 个任意类型的元素构成的元组。

  • 通过 infer 分别提取 One 和 Two 的元素到 infer 声明的局部变量 OneFirst、OneSecond、TwoFirst、TwoSecond 里。

用提取的元素构造成新的元组返回即可:

Array Zip

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
// 1️⃣ One Two参数的前提条件
function Zip(One is [unknown, unknown], Two is [unknown, unknown]) {
  // 2️⃣ 如果 One 满足 [OneFirst, OneSecond] 这种形式
  //     OneFirst, OneSecond 均为通过 infer 定义的局部变量,可以在 `子作用域` 中使用
  if (One satisfies [OneFirst, OneSecond]) { // 将 `extends` 替换为 `satisfies`
    // 3️⃣ 如果 Two 满足 [TwoFirst, TwoSecond] 这种形式
    if (Two satisfies [TwoFirst, TwoSecond]) {
      // 4️⃣ 就返回想要的结果
      return [[OneFirst, TwoFirst], [OneSecond, TwoSecond]]
    } else {
      // 如果不满足就返回 []
      return []
    }
  } else {
    return []
  }
}

🎉 但是这样只能合并两个元素的元组,如果是任意个呢?

那就得用递归了:

typescript
type Zip2<One extends unknown[], Two extends unknown[]> =
	One extends [infer OneFirst, ...infer OneRest]
		? Two extends [infer TwoFirst, ...infer TwoRest]
			? [[OneFirst, TwoFirst], ...Zip2<OneRest, TwoRest>]
			: []
		: []
  • 类型参数 One、Two 声明为 unknown[],也就是元素个数任意,类型任意的数组。

  • 每次提取 One 和 Two 的第一个元素 OneFirst、TwoFirst,剩余的放到 OneRest、TwoRest 里。

  • 用 OneFirst、TwoFirst 构造成新的元组的一个元素,剩余元素继续递归处理 OneRest、TwoRest。

这样,就能处理任意个数元组的合并:

Array Zip2

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function Zip2(One is unknown[], Two is unknown) {
  if (One satisfies [OneFirst, ...OneRest]) {
    if (Two Satisfies [TwoFirst, ...TwoRest]) {
      // 🚀 OneRest, TwoRest 表示数组中剩余元素的数组
      //    这里递归调用Zip2
      return [[OneFirst, TwoFirst], ...Zip2(OneRest, TwoRest)]
    } else {
      return []
    }
  } else {
    return []
  }
}

了解了数组类型的重新构造,我们再来看下字符串类型的.

字符串类型的重新构造

CapitalizeStr

我们想把一个字符串字面量类型的 'guang' 转为首字母大写的 'Guang'

需要用到字符串类型的提取和重新构造:

typescript
type CapitalizeStr<Str extends string> =
	Str extends `${infer First}${infer Rest}`
		? `${Uppercase<First>}${Rest}`
		: Str
  • 我们声明了类型参数 Str 是要处理的字符串类型,通过 extends 约束为 string。

  • 通过 infer 提取出首个字符到局部变量 First,提取后面的字符到局部变量 Rest。

  • 然后使用 TypeScript 提供的内置高级类型 Uppercase 把首字母转为大写,加上 Rest,构造成新的字符串类型返回。

String CapitalizeStr

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function CapitalizeStr(Str is string) {
  // 1️⃣ 将Str分解为 `First` + `ResStr` 形式
  if (Str satisfies First + RestStr) {
    // 2️⃣ 对 `First` 进行转换为大写形式
    return Uppercase<First> + RestStr
  } else {
    return Str
  }
}

🎉这就是字符串类型的重新构造:从已有的字符串类型中提取出一些部分字符串,经过一系列变换,构造成新的字符串类型。

CamelCase

我们再来实现 dong_dong_dongdongDongDong 的变换。

同样是提取和重新构造:(这个就要考虑递归处理多个模式匹配的情况了)

typescript
type CamelCase<Str extends string> =
	Str extends `${infer Left}_${infer RightFirstChar}${infer RestStr}`
		? `${Left}${Uppercase<RightFirstChar>}${CamelCase<RestStr>}`
		: Str
  • 类型参数 Str 是待处理的字符串类型,约束为 string。
  • 提取 _ 之前和之后的两个字符到 infer 声明的局部变量 LeftRightFirstChar,剩下的字符放到 RestStr 里。
  • 然后把右边的字符 RightFirstChar 大写,和 Left 构造成新的字符串,剩余的字符 RestStr 要继续递归的处理。

这样就完成了从下划线到驼峰形式的转换:

String CamelCase

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function CamelCase(Str is string) {
  // Str 分解为 `Left` + `_` + `RightFirstChar` + `RestStr`
  if (Str satisfies Left + _ + RightFirstChar + RestStr) {
    // 将 `RightFirstChar` 转换为大写
    // 并对后面字符做递归
    return Left + Uppercase<RightFirstChar> + CamelCase<RestStr>
  } else {
    return Str
  }
}

DropSubStr

可以修改自然也可以删除,我们再来做一个删除一段字符串的案例:删除字符串中的某个子串

typescript
type DropSubStr<
 	Str extends string, 
  SubStr extends string
> = 
	Str extends `${infer Prefix}${SubStr}${infer Suffix}`
		? DropSubStr<`${Prefix}${Suffix}`, SubStr>
		: Str
  • 类型参数 Str 是待处理的字符串, SubStr 是要删除的字符串,都通过 extends 约束为 string 类型。
  • 通过模式匹配提取 SubStr 之前和之后的字符串到 infer 声明的局部变量 Prefix、Suffix 中。
  • 如果不匹配就直接返回 Str。
  • 如果匹配,那就用 Prefix、Suffix 构造成新的字符串,然后继续递归删除 SubStr。直到不再匹配,也就是没有 SubStr 了。

String DropSubStr

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function DropSubStr(Str is string, SubStr is string) {
  if (Str satisfies Left + SubStr + Right) {
    // 递归
    return DropSubStr<Left + Right, SubStr>
  } else {
    return Str
  }
}

字符串类型的重新构造之后,我们再来看下函数类型的重新构造。

函数类型的重新构造

AppendArgument

之前我们分别实现了参数和返回值的提取,那么重新构造就是用这些提取出的类型做下修改,构造一个新的类型即可。

比如在已有的函数类型上添加一个参数:

typescript
type AppendArgument<Func extends Function, Arg> = 
	Func extends (...args: infer Args) => infer ReturnType
		? (...args: [...Args, Arg]) => ReturnType
		: never
  • 类型参数 Func 是待处理的函数类型,通过 extends 约束为 Function,Arg 是要添加的参数类型。
  • 通过模式匹配提取参数到 infer 声明的局部变量 Args 中,提取返回值到局部变量 ReturnType 中。
  • 用 Args 数组添加 Arg 构造成新的参数类型,结合 ReturnType 构造成新的函数类型返回。

这样就完成了函数类型的修改:

Function AppendArgument

最后,我们再来看下索引类型的重新构造

索引类型的重新构造

索引类型是聚合多个元素的类型,比如这就是一个索引类型:

typescript
type obj = {
  name: string;
  age: number;
  gender: boolean;
}

索引类型可以添加修饰符 readonly(只读)、?(可选):

typescript
type obj = {
  readonly name: string;
  age?: number;
  gender: boolean;
}

对它的修改和构造新类型涉及到了映射类型的语法:

typescript
type Mapping<Obj extends object> = { 
    [Key in keyof Obj]: Obj[Key]
}

Mappping

映射的过程中可以对 value 做下修改,比如:

typescript
type Mapping<Obj extends object> = { 
    [Key in keyof Obj]: [Obj[Key], Obj[Key], Obj[Key]]
}
  • 类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。

  • keyof 取出 Obj 的索引,作为新的索引类型的索引,也就是 Key in keyof Obj

  • 值的类型可以做变换,这里我们用之前索引类型的值 Obj[Key] 构造成了三个元素的元组类型 [Obj[Key], Obj[Key], Obj[Key]]

Index Mapping

索引类型的映射画下图很容易理解:

Index Mapping diagram

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function Mapping(Obj is object) {
  // 🎉 对Keys进行映射
  for (const Key of Object.keys(Obj)) {
    return [Obj[Key], Obj[Key], Obj[Key]]
  }
}

⚡ UppercaseKey

除了可以对 Value 做修改,也可以对 Key 做修改,使用 as,这叫做重映射

比如把索引类型的 Key 变为大写。

typescript
type UppercaseKey<Obj extends object> = {
	[Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]
}

// 💡 也可以提前对 Key 进行收窄 - 限定为 string
type UppercaseKey<Obj extends object> = {
  [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key] 
  [Key in string & keyof Obj as Uppercase<Key>]: Obj[Key] 
}
  • 类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。
  • 📚 新的索引类型的索引为 Obj 中的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的。
  • 📚 通过 Uppercase 把索引 Key 转为大写,因为索引可能为 string、number、symbol 类型,而这里只能接受 string 类型,所以要 & string,也就是取索引中 string 的部分。
  • value 保持不变,也就是之前的索引 Key 对应的值的类型 Obj[Key]。
  • 这样构造出的新的索引类型,就把原来索引类型的索引转为了大写

Index UppercaseKey

TIP

如何利用函数的思维理解上面类型重新构造。下面会使用伪代码的形式:

js
function UppercaseKey(Obj is object) {
  // 用 `as` 对 `Key` 进行改造
  // Uppercase<Key & string> 限定只要字符串类型的Key
  for (const Key as Uppercase<Key & string> of Object.keys(Obj)) {
    return {
      Uppercase<Key & string>: Obj[Key]
    }
  }
}

Record

TypeScript 提供了内置的高级类型 Record 来创建索引类型:

typescript
type Record<K extends string | number | symbol, T> = { [P in K]: T; }
  • 指定索引和值的类型分别为 KT,就可以创建一个对应的索引类型。
  • 上面的索引类型的约束我们用的 object,其实更语义化一点我推荐用 Record<string, object>
typescript
type UppercaseKey<Obj extends Record<string, any>> = { 
    [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]
}

也就是约束类型参数 Obj 为 key 为 string,值为任意类型的索引类型。

ToReadonly

索引类型的索引可以添加 readonly 的修饰符,代表只读。

那我们就可以实现给索引类型添加 readonly 修饰的高级类型:

typescript
type ToReadonly<T> = {
  readonly [Key in keyof T]: T[Key]
}
  • 通过映射类型构造了新的索引类型,给索引加上了 readonly 的修饰,其余的保持不变,索引依然为原来的索引 Key in keyof T,值依然为原来的值 T[Key]

Index ToReadonly

ToMutable

可以添加 readonly 修饰,当然也可以去掉

typescript
type ToMutable<T> = {
  // `-readonly`
  -readonly [Key in keyof T]: T[Key]
}
  • 给索引类型 T 的每个索引去掉 readonly 的修饰,其余保持不变。

Index ToMutable

ToPartial

同理,索引类型还可以添加可选修饰符

typescript
type ToPartial<T> = {
  [Key in keyof T]?: T[Key]
}
  • 给索引类型 T 的索引添加了 ? 可选修饰符,其余保持不变。

Index ToPartial

ToRequired

同理,也可以去掉可选修饰符:

typescript
type ToRequired<T> = {
  // `-?`
  [Key in keyof T]-?: T[Key]
}
  • 给索引类型 T 的索引去掉 ? 的修饰 ,其余保持不变。

Index ToRequired

⚡⚡ FilterByValueType

可以在构造新索引类型的时候根据值的类型做下过滤:

typescript
type FilterByValueType<
  Obj extends Record<string, any>,
  ValueType
> = {
  [Key in keyof Obj
  	as ValueType extends Obj[Key] ? Key : never
  ]: Obj[Key]
}
  • 类型参数 Obj 为要处理的索引类型,通过 extends 约束为索引为 string,值为任意类型的索引类型 Record<string, any>
  • 类型参数 ValueType 为要过滤出的值的类型。
  • 构造新的索引类型,索引为 Obj 的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的部分。
  • 📚 如果原来索引的值 Obj[Key]ValueType 类型,索引依然为之前的索引 Key,否则索引设置为 nevernever 的索引会在生成新的索引类型时被去掉🎉。
  • 值保持不变,依然为原来索引的值,也就是 Obj[Key]。
  • 这样就达到了过滤索引类型的索引,产生新的索引类型的目的

Index FilterByValueType

PS: 利用这个规则可以去掉Obj[Key]never 类型的值:

typescript
type FilterNeverType<Obj extends Record<string, any>> = {
  // as 后面的Key 还可以使用 `extends` 进行条件约束🎉
  [Key in keyof Obj
    as Obj[Key] extends never ? never : Key
  ]: Obj[Key]
}

Index filter never type

总结

TypeScript 支持 type、infer、类型参数来保存任意类型,相当于变量的作用。

但其实也不能叫变量,因为它们是不可变的。想要变化就需要重新构造新的类型,并且可以在构造新类型的过程中对原类型做一些过滤和变换。

数组、字符串、函数、索引类型等都可以用这种方式对原类型做变换产生新的类型。其中索引类型有专门的语法叫做映射类型,对索引做修改的 as 叫做重映射

提取和构造这俩是相辅相成的,学完了模式匹配做提取重新构造做变换 这两个套路之后,很多类型体操就有思路了。

2023年03月08日14:24:33