Typescript进阶之类型体操套路二

133 阅读14分钟

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

TypeScript 类型系统支持 3 种可以声明任意类型的变量: type、infer、类型参数。

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

type ttt = Promise<number>;

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

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

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

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

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

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

答案是重新构造。

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

重新构造

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

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

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

我们先从简单的开始:

数组类型的重新构造

Push

有这样一个元组类型:

type tuple = [1,2,3];

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

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

type Push<Arr extends  unknown[], Ele> = [...Arr, Ele];
​

解释

  1. 泛型参数 Arr extends unknown[]Ele

    • Arr 是一个泛型参数,表示传入的类型必须是一个数组。
    • Ele 是另一个泛型参数,表示要添加到数组中的新元素的类型。
  2. 类型别名 Push<Arr extends unknown[], Ele>

    • ...Arr 是一个扩展运算符,用于将现有的数组 Arr 展开。
    • Ele 表示在展开的数组后面添加新的元素 Ele

类型参数 Arr 是要修改的数组/元组类型,元素的类型任意,也就是 unknown。

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

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

// 示例 1: 在数字数组中添加一个数字
type NewNumberArray = Push<[1, 2, 3], 4>; // [1, 2, 3, 4]

// 示例 2: 在字符串数组中添加一个字符串
type NewStringArray = Push<['a', 'b', 'c'], 'd'>; // ['a', 'b', 'c', 'd']

// 示例 3: 在混合数组中添加一个布尔值
type NewMixedArray = Push<[1, 'a', true], false>; // [1, 'a', true, false]

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

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

Unshift

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

type Unshift<Arr extends  unknown[], Ele> = [Ele, ...Arr];
​

字符串类型的重新构造

CapitalizeStr

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

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

type CapitalizeStr<Str extends string> = 
    Str extends `${infer First}${infer Rest}` 
        ? `${Uppercase<First>}${Rest}` : Str;
​

解释

  1. 泛型参数 Str extends string

    • Str 是一个泛型参数,表示传入的类型必须是一个字符串。
  2. 条件类型 Str extends 𝑖𝑛𝑓𝑒𝑟𝐹𝑖𝑟𝑠𝑡inferFirst{infer Rest}`

    • ${infer First}${infer Rest} 是一个模板字符串类型的模式匹配。
    • infer First 用于从字符串 Str 中推断出第一个字符。
    • infer Rest 用于从字符串 Str 中推断出剩余部分。
  3. 条件类型的结果

    • 如果 Str 匹配到 ${infer First}${infer Rest} 这个模式,则返回 ${Uppercase<First>}${Rest},即将第一个字符转换为大写,然后拼接剩余部分。
    • 否则,返回原始字符串 Str

我们声明了类型参数 Str 是要处理的字符串类型,通过 extends 约束为 string。

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

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

// 示例 1: 首字母小写
type CapitalizedHello = CapitalizeStr<'hello'>; // 'Hello'

// 示例 2: 首字母已经是大写
type CapitalizedWorld = CapitalizeStr<'World'>; // 'World'

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

CamelCase

我们再来实现 dong_dong_dong 到 dongDongDong 的变换。

同样是提取和重新构造:

type CamelCase<Str extends string> = 
    Str extends `${infer Left}_${infer Right}${infer Rest}`
        ? `${Left}${Uppercase<Right>}${CamelCase<Rest>}`
        : Str;
​

解释

  1. 泛型参数 Str extends string

    • Str 是一个泛型参数,表示传入的类型必须是一个字符串。
  2. 条件类型 Str extends {infer Left}_{infer Right}${infer Rest}`

    • ${infer Left}_${infer Right}${infer Rest} 是一个模板字符串类型的模式匹配。
    • infer Left 用于从字符串 Str 中推断出下划线 _ 之前的部分。
    • infer Right 用于从字符串 Str 中推断出下划线 _ 之后的第一个字符。
    • infer Rest 用于从字符串 Str 中推断出下划线 _ 之后的剩余部分。
  3. 条件类型的结果

    • 如果 Str 匹配到 ${infer Left}_${infer Right}${infer Rest} 这个模式,则返回 ${Left}${Uppercase<Right>}${CamelCase<Rest>},即将下划线 _ 之后的第一个字符转换为大写,并递归地处理剩余部分。
    • 否则,返回原始字符串 Str

类型参数 Str 是待处理的字符串类型,约束为 string。

提取 _ 之前和之后的两个字符到 infer 声明的局部变量 Left 和 Right,剩下的字符放到 Rest 里。

然后把右边的字符 Right 大写,和 Left 构造成新的字符串,剩余的字符 Rest 要继续递归的处理。

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

// 示例 1: 转换简单的下划线字符串
type CamelCaseExample = CamelCase<'hello_world'>; // 'helloWorld'

// 示例 2: 转换多级下划线字符串
type CamelCaseMulti = CamelCase<'first_name_last_name'>; // 'firstNameLastName'

DropSubStr

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

type DropSubStr<Str extends string, SubStr extends string> = 
    Str extends `${infer Prefix}${SubStr}${infer Suffix}` 
        ? DropSubStr<`${Prefix}${Suffix}`, SubStr> : Str;
​

解释

  1. 泛型参数 Str extends stringSubStr extends string

    • Str 是一个泛型参数,表示传入的类型必须是一个字符串。
    • SubStr 是另一个泛型参数,表示要从 Str 中删除的子字符串。
  2. 条件类型 Str extends inferPrefix{infer Prefix}{SubStr}${infer Suffix}`

    • ${infer Prefix}${SubStr}${infer Suffix} 是一个模板字符串类型的模式匹配。
    • infer Prefix 用于从字符串 Str 中推断出子字符串 SubStr 之前的部分。
    • infer Suffix 用于从字符串 Str 中推断出子字符串 SubStr 之后的部分。
  3. 条件类型的结果

    • 如果 Str 匹配到 ${infer Prefix}${SubStr}${infer Suffix} 这个模式,则返回 DropSubStr<𝑃𝑟𝑒𝑓𝑖𝑥Prefix{Suffix}, SubStr>,即递归地删除 SubStr 直到 Str 中不再包含 SubStr
    • 否则,返回原始字符串 Str

类型参数 Str 是待处理的字符串, SubStr 是要删除的字符串,都通过 extends 约束为 string 类型。

通过模式匹配提取 SubStr 之前和之后的字符串到 infer 声明的局部变量 Prefix、Suffix 中。

如果不匹配就直接返回 Str。

如果匹配,那就用 Prefix、Suffix 构造成新的字符串,然后继续递归删除 SubStr。直到不再匹配,也就是没有 SubStr 了。


// 示例 1: 删除单个子字符串
type DropSingle = DropSubStr<'hello world', 'world'>; // 'hello '

// 示例 2: 删除多个子字符串
type DropMultiple = DropSubStr<'hello world world', 'world'>; // 'hello '

函数类型的重新构造:

AppendArgument

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

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

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 构造成新的函数类型返回。

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

// 示例 1: 追加一个数字参数
type AppendNumber = AppendArgument<(a: string) => void, number>; // (a: string, arg: number) => void

// 示例 2: 追加一个字符串参数
type AppendString = AppendArgument<(a: number, b: boolean) => string, string>; // (a: number, b: boolean, arg: string) => string

索引类型的重新构造

索引类型是聚合多个元素的类型,class、对象等都是索引类型,比如这就是一个索引类型:

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

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

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

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

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

Mapping

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

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

解释

  1. 泛型参数 Obj extends object

    • Obj 是一个泛型参数,表示传入的类型必须是一个对象。
  2. 映射类型 [Key in keyof Obj]

    • keyof Obj 获取对象 Obj 的所有键。
    • [Key in keyof Obj] 是一个映射类型,用于遍历对象 Obj 的所有键。
  3. 属性值转换 [Obj[Key], Obj[Key], Obj[Key]]

    • 对于每个键 Key,将对应的属性值 Obj[Key] 转换为一个包含三个相同值的数组 [Obj[Key], Obj[Key], Obj[Key]]

类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。

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

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

// 示例 1: 基本对象
type MappedObject1 = Mapping<{ a: number, b: string }>;
// 结果:{ a: [number, number, number], b: [string, string, string] }

UppercaseKey

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

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

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

解释

  1. 泛型参数 Obj extends object

    • Obj 是一个泛型参数,表示传入的类型必须是一个对象。
  2. 映射类型 [Key in keyof Obj as Uppercase<Key & string>]

    • keyof Obj 获取对象 Obj 的所有键。
    • [Key in keyof Obj as Uppercase<Key & string>] 是一个映射类型,用于遍历对象 Obj 的所有键,并将每个键转换为大写。
    • Key & string 确保键是字符串类型,因为 Uppercase 只能应用于字符串类型。
    • as Uppercase<Key & string> 使用 as 关键字将键转换为大写。
  3. 属性值保持不变 Obj[Key]

    • 对于每个键 Key,属性值保持不变,仍然是 Obj[Key]

类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。

新的索引类型的索引为 Obj 中的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的。

通过 Uppercase 把索引 Key 转为大写,因为索引可能为 string、number、symbol 类型,而这里只能接受 string 类型,所以要 & string,也就是取索引中 string 的部分。

value 保持不变,也就是之前的索引 Key 对应的值的类型 Obj[Key]。

这样构造出的新的索引类型,就把原来索引类型的索引转为了大写:

// 示例 1: 基本对象
type UppercasedObject1 = UppercaseKey<{ a: number, b: string }>;
// 结果:{ A: number, B: string }

// 示例 2: 包含布尔值的对象
type UppercasedObject2 = UppercaseKey<{ c: boolean, d: boolean }>;
// 结果:{ C: boolean, D: boolean }

Record

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

type Record<K extends string | number | symbol, T> = { [P in K]: T; }
​

解释

  1. 泛型参数 K extends string | number | symbolT

    • K 是一个泛型参数,表示传入的类型必须是 stringnumber 或 symbol 中的一种或多种的联合类型。
    • T 是另一个泛型参数,表示对象中每个键的值类型。
  2. 映射类型 [P in K]

    • [P in K] 是一个映射类型,用于遍历联合类型 K 中的所有成员。
    • 对于每个成员 P,创建一个键值对,键是 P,值类型是 T

指定索引和值的类型分别为 K 和 T,就可以创建一个对应的索引类型。

// 示例 1: 使用字符串键
type StringKeys = Record<'a' | 'b' | 'c', string>;
// 结果:{ a: string, b: string, c: string }

// 示例 2: 使用数字键
type NumberKeys = Record<1 | 2 | 3, boolean>;
// 结果:{ 1: boolean, 2: boolean, 3: boolean }

上面的索引类型的约束我们用的 object,其实更语义化一点我推荐用 Record<string, any>:

type UppercaseKey<Obj extends Record<string, any>> = { 
    [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]
}
​

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

ToReadonly

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

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

type ToReadonly<T> =  {
    readonly [Key in keyof T]: T[Key];
}
​

解释

  1. 泛型参数 T

    • T 是一个泛型参数,表示传入的类型必须是一个对象。
  2. 映射类型 [Key in keyof T]

    • keyof T 获取对象 T 的所有键。
    • [Key in keyof T] 是一个映射类型,用于遍历对象 T 的所有键。
  3. 只读修饰符 readonly

    • readonly [Key in keyof T] 使用 readonly 修饰符将每个键标记为只读。
    • 这意味着生成的对象类型的属性不能被修改。
  4. 属性值保持不变 T[Key]

    • 对于每个键 Key,属性值保持不变,仍然是 T[Key]

通过映射类型构造了新的索引类型,给索引加上了 readonly 的修饰,其余的保持不变,索引依然为原来的索引 Key in keyof T,值依然为原来的值 T[Key]。

// 示例 1: 基本对象
type ReadonlyObject1 = ToReadonly<{ a: number, b: string }>;
// 结果:{ readonly a: number, readonly b: string }

// 示例 2: 包含布尔值的对象
type ReadonlyObject2 = ToReadonly<{ c: boolean, d: boolean }>;
// 结果:{ readonly c: boolean, readonly d: boolean }

ToPartial

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

type ToPartial<T> = {
    [Key in keyof T]?: T[Key]
}

解释

  1. 泛型参数 T

    • T 是一个泛型参数,表示传入的类型必须是一个对象。
  2. 映射类型 [Key in keyof T]

    • keyof T 获取对象 T 的所有键。
    • [Key in keyof T] 是一个映射类型,用于遍历对象 T 的所有键。
  3. 可选修饰符 ?

    • [Key in keyof T]? 使用 ? 修饰符将每个键标记为可选。
    • 这意味着生成的对象类型的属性可以是可选的。
  4. 属性值保持不变 T[Key]

    • 对于每个键 Key,属性值保持不变,仍然是 T[Key]

给索引类型 T 的索引添加了 ? 可选修饰符,其余保持不变。


// 示例 1: 基本对象
type PartialObject1 = ToPartial<{ a: number, b: string }>;
// 结果:{ a?: number, b?: string }

// 示例 2: 包含布尔值的对象
type PartialObject2 = ToPartial<{ c: boolean, d: boolean }>;
// 结果:{ c?: boolean, d?: boolean }

FilterByValueType

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

type FilterByValueType<
    Obj extends Record<string, any>, 
    ValueType
> = {
    [Key in keyof Obj 
        as Obj[Key] extends ValueType ? Key : never]
        : Obj[Key]
}
​

解释

  1. 泛型参数 Obj extends Record<string, any>ValueType

    • Obj 是一个泛型参数,表示传入的类型必须是一个对象,其键是字符串,值可以是任何类型。
    • ValueType 是另一个泛型参数,表示要过滤的值类型。
  2. 映射类型 [Key in keyof Obj as Obj[Key] extends ValueType ? Key : never]

    • keyof Obj 获取对象 Obj 的所有键。

    • [Key in keyof Obj as Obj[Key] extends ValueType ? Key : never] 是一个映射类型,用于遍历对象 Obj 的所有键。

    • as Obj[Key] extends ValueType ? Key : never 使用条件类型来决定是否保留当前键:

      • 如果 Obj[Key] 的类型是 ValueType,则保留键 Key
      • 否则,使用 never 表示不保留该键。
  3. 属性值保持不变 Obj[Key]

    • 对于每个保留的键 Key,属性值保持不变,仍然是 Obj[Key]

类型参数 Obj 为要处理的索引类型,通过 extends 约束为索引为 string,值为任意类型的索引类型 Record<string, any>。

类型参数 ValueType 为要过滤出的值的类型。

构造新的索引类型,索引为 Obj 的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的部分。

如果原来索引的值 Obj[Key] 是 ValueType 类型,索引依然为之前的索引 Key,否则索引设置为 never,never 的索引会在生成新的索引类型时被去掉。

值保持不变,依然为原来索引的值,也就是 Obj[Key]。

这样就达到了过滤索引类型的索引,产生新的索引类型的目的:

// 示例 1: 过滤出字符串类型的属性
type FilteredObject1 = FilterByValueType<{ a: string, b: number, c: boolean }, string>;
// 结果:{ a: string }

// 示例 2: 过滤出数字类型的属性
type FilteredObject2 = FilterByValueType<{ a: string, b: number, c: boolean }, number>;
// 结果:{ b: number }

总结

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

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

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

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