Typescript进阶之类型体操套路三

277 阅读11分钟

套路三:递归复用做循环

会做类型的提取和构造之后,我们已经能写出很多类型编程逻辑了,但是有时候提取或构造的数组元素个数不确定、字符串长度不确定、对象层数不确定。这时候怎么办呢?

其实前面的案例我们已经涉及到了一些,就是递归。

这就是第三个类型体操套路:递归复用做循环。

递归复用

递归是把问题分解为一系列相似的小问题,通过函数不断调用自身来解决这一个个小问题,直到满足结束条件,就完成了问题的求解。

TypeScript 的高级类型支持类型参数,可以做各种类型运算逻辑,返回新的类型,和函数调用是对应的,自然也支持递归。

TypeScript 类型系统不支持循环,但支持递归。当处理数量(个数、长度、层数)不固定的类型的时候,可以只处理一个类型,然后递归的调用自身处理下一个类型,直到结束条件也就是所有的类型都处理完了,就完成了不确定数量的类型编程,达到循环的效果。

既然提到了数组、字符串、对象等类型,那么我们就来看一下这些类型的递归案例吧。

Promise 的递归复用

DeepPromiseValueType

先用 Promise 热热身,实现一个提取不确定层数的 Promise 中的 value 类型的高级类型。

type ttt = Promise<Promise<Promise<Record<string, any>>>>;
​

这里是 3 层 Promise,value 类型是索引类型。

数量不确定,一涉及到这个就要想到用递归来做,每次只处理一层的提取,然后剩下的到下次递归做,直到结束条件。

所以高级类型是这样的:

type DeepPromiseValueType<P extends Promise<unknown>> =
    P extends Promise<infer ValueType> 
        ? ValueType extends Promise<unknown>
            ? DeepPromiseValueType<ValueType>
            : ValueType
        : never;
​

解释

  1. 泛型参数 P extends Promise<unknown>

    • P 是一个泛型参数,表示传入的类型必须是一个 Promise
    • unknown 表示 Promise 的值类型可以是任何类型。
  2. 条件类型 P extends Promise<infer ValueType>

    • P extends Promise<infer ValueType> 使用 infer 关键字从 P 中推断出 Promise 的值类型 ValueType
  3. 递归处理 ValueType extends Promise<unknown>

    • ValueType extends Promise<unknown> 检查 ValueType 是否也是一个 Promise
    • 如果 ValueType 是 Promise,则递归调用 DeepPromiseValueType<ValueType> 继续处理嵌套的 Promise
    • 如果 ValueType 不是 Promise,则直接返回 ValueType
  4. 默认情况 : never

    • 如果 P 不是 Promise,则返回 never 类型,表示没有匹配到任何值。

类型参数 P 是待处理的 Promise,通过 extends 约束为 Promise 类型,value 类型不确定,设为 unknown。

每次只处理一个类型的提取,也就是通过模式匹配提取出 value 的类型到 infer 声明的局部变量 ValueType 中。

然后判断如果 ValueType 依然是 Promise类型,就递归处理。

结束条件就是 ValueType 不为 Promise 类型,那就处理完了所有的层数,返回这时的 ValueType。

这样,我们就提取到了最里层的 Promise 的 value 类型,也就是索引类型:


// 示例 1: 单层 Promise
type SingleLayerPromise = DeepPromiseValueType<Promise<string>>;
// 结果:string

// 示例 2: 双层 Promise
type DoubleLayerPromise = DeepPromiseValueType<Promise<Promise<number>>>;
// 结果:number
// 示例 3: 复杂嵌套 Promise
type ComplexNestedPromise = DeepPromiseValueType<Promise<{ data: Promise<{ id: number }> }>>;
// 结果:{ data: { id: number } }
  • P 是 Promise<{ data: Promise<{ id: number }> }>
  • ValueType 是 { data: Promise<{ id: number }> }
  • ValueType 不是 Promise,因此结果是 { data: Promise<{ id: number }> }
  • 但是 data 属性是 Promise,因此需要进一步处理。
  • data 的 ValueType 是 { id: number }
  • { id: number } 不是 Promise,因此结果是 { data: { id: number } }

其实这个类型的实现可以进一步的简化:

type DeepPromiseValueType2<T> = 
    T extends Promise<infer ValueType> 
        ? DeepPromiseValueType2<ValueType>
        : T;
​

不再约束类型参数必须是 Promise,这样就可以少一层判断。

解释

  1. 泛型参数 T

    • T 是一个泛型参数,表示传入的类型可以是任何类型,包括 Promise
  2. 条件类型 T extends Promise<infer ValueType>

    • T extends Promise<infer ValueType> 使用 infer 关键字从 T 中推断出 Promise 的值类型 ValueType
    • 如果 T 是 Promise,则 ValueType 将是 Promise 的值类型。
  3. 递归处理 DeepPromiseValueType2<ValueType>

    • 如果 T 是 Promise,则递归调用 DeepPromiseValueType2<ValueType> 继续处理嵌套的 Promise
    • 如果 T 不是 Promise,则直接返回 T

数组类型的递归

ReverseArr

有这样一个元组类型:

type arr = [1,2,3,4,5];

我们把它反过来,也就是变成:

type arr = [5,4,3,2,1];

这个学完了提取和构造很容易写出来:

type ReverseArr<Arr extends unknown[]> = 
    Arr extends [infer One, infer Two, infer Three, infer Four, infer Five]
        ? [Five, Four, Three, Two, One]
        : never;
​

但如果数组长度不确定呢?

数量不确定,条件反射的就要想到递归。

我们每次只处理一个类型,剩下的递归做,直到满足结束条件。

type ReverseArr<Arr extends unknown[]> = 
    Arr extends [infer First, ...infer Rest] 
        ? [...ReverseArr<Rest>, First] 
        : Arr;
​

解释

  1. 泛型参数 Arr extends unknown[]

    • Arr 是一个泛型参数,表示传入的类型必须是一个数组。
  2. 条件类型 Arr extends [infer First, ...infer Rest]

    • Arr extends [infer First, ...infer Rest] 使用 infer 关键字从 Arr 中推断出第一个元素 First 和剩余元素 Rest
    • First 是数组的第一个元素。
    • Rest 是数组的剩余元素,仍然是一个数组。
  3. 递归处理 [...ReverseArr<Rest>, First]

    • 如果 Arr 是一个非空数组,则递归调用 ReverseArr<Rest> 处理剩余元素。
    • 将递归处理后的结果与 First 元素拼接在一起,形成一个新的数组。
  4. 默认情况 : Arr

    • 如果 Arr 是一个空数组,则直接返回 Arr

类型参数 Arr 为待处理的数组类型,元素类型不确定,也就是 unknown。

每次只处理一个元素的提取,放到 infer 声明的局部变量 First 里,剩下的放到 Rest 里。

用 First 作为最后一个元素构造新数组,其余元素递归的取。

结束条件就是取完所有的元素,也就是不再满足模式匹配的条件,这时候就返回 Arr。

// 示例 2: 单个元素数组
type ReversedSingleElementArray = ReverseArr<[1]>;
// 结果:[1]

// 示例 3: 双元素数组
type ReversedTwoElementArray = ReverseArr<[1, 2]>;
// 结果:[2, 1]

// 示例 4: 多元素数组
type ReversedMultiElementArray = ReverseArr<[1, 2, 3, 4, 5]>;
// 结果:[5, 4, 3, 2, 1]

// 示例 5: 包含不同类型的数组
type ReversedMixedTypeArray = ReverseArr<[1, 'two', true, { key: 'value' }]>;
// 结果:[{ key: 'value' }, true, 'two', 1]

字符串类型的递归

ReplaceAll

学模式匹配的时候,我们实现过一个 Replace 的高级类型:

type ReplaceStr<
    Str extends string,
    From extends string,
    To extends string
> = Str extends `${infer Prefix}${From}${infer Suffix}` 
    ? `${Prefix}${To}${Suffix}` : Str;
​

它能把一个字符串中的某个字符替换成另一个:

但是如果有多个这样的字符就处理不了了。

如果不确定有多少个 From 字符,怎么处理呢?

在类型体操里,遇到数量不确定的问题,就要条件反射的想到递归。

每次递归只处理一个类型,这部分我们已经实现了,那么加上递归的调用就可以。


// 示例 1: 替换单个子字符串
type ReplacedString1 = ReplaceStr<'hello world', 'world', 'TypeScript'>;
// 结果:'hello TypeScript'

// 示例 2: 替换多个相同的子字符串
type ReplacedString2 = ReplaceStr<'foo bar foo', 'foo', 'baz'>;
type ReplaceAll<
    Str extends string, 
    From extends string, 
    To extends string
> = Str extends `${infer Left}${From}${infer Right}`
        ? `${Left}${To}${ReplaceAll<Right, From, To>}`
        : Str;
​

解释

  1. 泛型参数 Str extends string, From extends string, To extends string

    • Str 是一个泛型参数,表示传入的类型必须是一个字符串。
    • From 是一个泛型参数,表示要被替换的子字符串。
    • To 是一个泛型参数,表示替换后的新子字符串。
  2. 条件类型 Str extends inferLeft{infer Left}{From}${infer Right}`

    • Str extends inferLeft{infer Left}{From}${infer Right}使用模板字符串和infer关键字从Str中推断出前缀Left、要被替换的子字符串 From和后缀Right`。
    • Left 是 Str 中 From 之前的部分。
    • Right 是 Str 中 From 之后的部分。
  3. 递归替换 ${Left}${To}${ReplaceAll<Right, From, To>}

    • 如果 Str 中包含 From,则将 From 替换为 To,并将剩余的字符串 Right 递归地传递给 ReplaceAll 进行进一步处理。
    • 最终结果是 ${Left}${To}${ReplaceAll<Right, From, To>}
  4. 默认情况 : Str

    • 如果 Str 中不包含 From,则直接返回 Str

类型参数 Str 是待处理的字符串类型,From 是待替换的字符,To 是替换到的字符。

通过模式匹配提取 From 左右的字符串到 infer 声明的局部变量 Left 和 Right 里。

用 Left 和 To 构造新的字符串,剩余的 Right 部分继续递归的替换。

结束条件是不再满足模式匹配,也就是没有要替换的元素,这时就直接返回字符串 Str。

这样就实现了任意数量的字符串替换:


// 示例 1: 替换单个子字符串
type ReplacedString1 = ReplaceAll<'hello world', 'world', 'TypeScript'>;
// 结果:'hello TypeScript'

StringToUnion

我们想把字符串字面量类型的每个字符都提取出来组成联合类型,也就是把 'dong' 转为 'd' | 'o' | 'n' | 'g'。

怎么做呢?

很明显也是提取和构造:

type StringToUnion<Str extends string> = 
    Str extends `${infer One}${infer Two}${infer Three}${infer Four}`
        ? One | Two | Three | Four
        : never;
​

但如果字符串长度不确定呢?

数量不确定,在类型体操中就要条件反射的想到递归。

type StringToUnion<Str extends string> = 
    Str extends `${infer First}${infer Rest}`
        ? First | StringToUnion<Rest>
        : never;
​

解释

  1. 泛型参数 Str extends string

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

    • Str extends {infer First}${infer Rest}使用模板字符串和infer关键字从Str中推断出第一个字符First和剩余字符串Rest`。
    • First 是字符串的第一个字符。
    • Rest 是字符串的剩余部分。
  3. 递归处理 First | StringToUnion<Rest>

    • 如果 Str 是一个非空字符串,则递归调用 StringToUnion<Rest> 处理剩余字符串。
    • 将第一个字符 First 与递归处理后的结果合并成一个联合类型。
  4. 默认情况 : never

    • 如果 Str 是一个空字符串,则返回 never 类型。

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

通过模式匹配提取第一个字符到 infer 声明的局部变量 First,其余的字符放到局部变量 Rest。

用 First 构造联合类型,剩余的元素递归的取。

这样就完成了不确定长度的字符串的提取和联合类型的构造:

// 示例 1: 多个字符字符串
type MultiCharUnion = StringToUnion<'abc'>;
// 结果:'a' | 'b' | 'c'

// 示例 2: 包含空格的字符串
type SpaceStringUnion = StringToUnion<'a b c'>;
// 结果:'a' | ' ' | 'b' | ' ' | 'c'

ReverseStr

我们实现了数组的反转,自然也可以实现字符串类型的反转。

同样是递归提取和构造。

type ReverseStr<
    Str extends string, 
    Result extends string = ''
> = Str extends `${infer First}${infer Rest}` 
    ? ReverseStr<Rest, `${First}${Result}`> 
    : Result;
​

解释

  1. 泛型参数 Str extends stringResult extends string = ''

    • Str 是一个泛型参数,表示传入的类型必须是一个字符串。
    • Result 是一个可选的泛型参数,默认值为空字符串 '',用于存储反转后的结果。
  2. 条件类型 Str extends {infer First}${infer Rest}`

    • Str extends {infer First}${infer Rest}使用模板字符串和infer关键字从Str中推断出第一个字符First和剩余字符串Rest`。
    • First 是字符串的第一个字符。
    • Rest 是字符串的剩余部分。
  3. 递归处理 ReverseStr<Rest, 𝐹𝑖𝑟𝑠𝑡First{Result}>

    • 如果 Str 是一个非空字符串,则递归调用 ReverseStr<Rest, 𝐹𝑖𝑟𝑠𝑡First{Result}>
    • 将第一个字符 First 添加到结果字符串 Result 的前面。
    • 继续处理剩余字符串 Rest
  4. 默认情况 : Result

    • 如果 Str 是一个空字符串,则返回当前的结果字符串 Result

类型参数 Str 为待处理的字符串。类型参数 Result 为构造出的字符,默认值是空串。

通过模式匹配提取第一个字符到 infer 声明的局部变量 First,其余字符放到 Rest。

用 First 和之前的 Result 构造成新的字符串,把 First 放到前面,因为递归是从左到右处理,那么不断往前插就是把右边的放到了左边,完成了反转的效果。

直到模式匹配不满足,就处理完了所有的字符。

这样就完成了字符串的反转:

type ReversedString3 = ReverseStr<'abc'>;
// 结果:'cba'

对象类型的递归

DeepReadonly

对象类型的递归,也可以叫做索引类型的递归。

我们之前实现了索引类型的映射,给索引加上了 readonly 的修饰:

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

如果这个索引类型层数不确定呢?

比如这样:

type obj = {
    a: {
        b: {
            c: {
                f: () => 'dong',
                d: {
                    e: {
                        guang: string
                    }
                }
            }
        }
    }
}
​

数量(层数)不确定,类型体操中应该自然的想到递归。

我们在之前的映射上加入递归的逻辑:

type DeepReadonly<Obj extends Record<string, any>> = {
    readonly [Key in keyof Obj]:
        Obj[Key] extends object
            ? Obj[Key] extends Function
                ? Obj[Key] 
                : DeepReadonly<Obj[Key]>
            : Obj[Key]
}

解释

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

    • Obj 是一个泛型参数,表示传入的类型必须是一个对象,其键是字符串,值可以是任何类型。
  2. 映射类型 readonly [Key in keyof Obj]

    • 使用映射类型将 Obj 的所有键 Key 标记为只读。
    • readonly [Key in keyof Obj] 表示遍历 Obj 的所有键,并将每个键标记为只读。
  3. 条件类型 Obj[Key] extends object

    • 检查 Obj[Key] 是否是一个对象。
    • 如果 Obj[Key] 是一个对象,则进一步检查它是否是一个函数。
  4. 条件类型 Obj[Key] extends Function

    • 检查 Obj[Key] 是否是一个函数。
    • 如果 Obj[Key] 是一个函数,则直接返回 Obj[Key],因为函数本身不需要变成只读。
  5. 递归处理 DeepReadonly<Obj[Key]>

    • 如果 Obj[Key] 是一个对象且不是一个函数,则递归调用 DeepReadonly<Obj[Key]>,将嵌套的对象也标记为只读。
  6. 默认情况 : Obj[Key]

    • 如果 Obj[Key] 不是对象(例如,基本类型如字符串、数字等),则直接返回 Obj[Key]

类型参数 Obj 是待处理的索引类型,约束为 Record<string, any>,也就是索引为 string,值为任意类型的索引类型。

索引映射自之前的索引,也就是 Key in keyof Obj,只不过加上了 readonly 的修饰。

值要做下判断,如果是 object 类型并且还是 Function,那么就直接取之前的值 Obj[Key]。

如果是 object 类型但不是 Function,那就是说也是一个索引类型,就递归处理 DeepReadonly<Obj[Key]>。

否则,值不是 object 就直接返回之前的值 Obj[Key]。

这样就完成了任意层数的索引类型的添加 readonly 修饰:


// 示例 2: 嵌套对象
type ReadonlyObj2 = DeepReadonly<{
    a: number,
    b: {
        c: string,
        d: {
            e: boolean
        }
    }
}>;
// 结果:{
//     readonly a: number;
//     readonly b: {
//         readonly c: string;
//         readonly d: {
//             readonly e: boolean;
//         };
//     };
// }

总结

递归是把问题分解成一个个子问题,通过解决一个个子问题来解决整个问题。形式是不断的调用函数自身,直到满足结束条件。

在 TypeScript 类型系统中的高级类型也同样支持递归,在类型体操中,遇到数量不确定的问题,要条件反射的想到递归。  比如数组长度不确定、字符串长度不确定、索引类型层数不确定等。

如果说学完了提取和构造可以做一些基础的类型体操,那再加上递归就可以实现各种复杂类型体操了。

本文案例的合并