面试官:如何在类型体操内实现 if 和 for 循环 🫠🫠🫠

914 阅读9分钟

类型体操是 TypeScript 中一种强大的类型编程方式,能够在类型层面实现复杂逻辑。它依赖 TypeScript 的条件类型、映射类型、推导类型和递归类型等特性,广泛用于动态类型推导、类型转换和高级类型安全逻辑的实现

在 TypeScript 类型体操中,if 和 for 的实现主要依赖于条件类型(Conditional Types)、递归类型(Recursive Types)以及元组操作。

实现 if 逻辑

TypeScript 中的条件类型类似于 JavaScript 的三元运算符 condition ? trueValue : falseValue。在类型层面实现 if,依赖于条件类型:

type If<Condition, TrueType, FalseType> = Condition extends true
  ? TrueType
  : FalseType;

如下代码使用示例:

type IsTrue = If<true, "Yes", "No">; // 'Yes'
type IsFalse = If<false, "Yes", "No">; // 'No'

20250103160315

条件可以不仅仅是布尔值,我们还可以通过分配性条件类型判断更多复杂逻辑。例如:

type If<Condition extends boolean, TrueType, FalseType> = Condition extends true
  ? TrueType
  : FalseType;

type IsString<T> = If<T extends string ? true : false, "String", "Not String">;

最终使用效果如下图所示:

20250103161642

实现 for 循环

在 TypeScript 类型系统中,没有直接的循环机制。因此,for 循环通常通过递归类型的方式实现。递归的思想是,将操作应用在一组类型上,不断减少问题规模,直到满足终止条件。

基本实现

最基本的递归模式如下:

type Loop<
  T extends number,
  Result extends any[] = []
> = Result["length"] extends T ? Result : Loop<T, [...Result, 1]>;

使用示例如吓图所示:

20250103163245

在上面的这个代码示例中展示了使用数组长度作为计算器,通过递归来累积结果,并使用条件类型来判断终止条件。

遍历数组

遍历数组是我们最常见的循环操作之一:

type MapArray<
  Arr extends any[],
  Handler extends (item: any) => any
> = Arr extends [infer First, ...infer Rest]
  ? [ReturnType<Handler>, ...MapArray<Rest, Handler>]
  : ReturnType<Handler>[];

// 使用示例
type StringArray = MapArray<[1, 2, 3], (n: number) => string>; // [string, string, string]

MapArray 利用 TypeScript 的条件类型和递归,将数组类型逐个拆分为首个元素 First 和剩余元素 Rest。通过对 First 应用处理函数 Handler 的返回类型,并递归处理 Rest,逐步构造出一个新的数组类型。最终,当数组为空时,递归终止并返回空数组类型,模拟了 Array.map 的逻辑但作用于类型层面。

接下来我们来使用一个具体例子来展示如何使用 MapArray 来实现 for 循环:

type MapArray<
  Arr extends any[],
  Handler extends (item: any) => any
> = Arr extends [infer First, ...infer Rest]
  ? [ReturnType<Handler>, ...MapArray<Rest, Handler>]
  : ReturnType<Handler>[];

type NumberToString = (n: number) => string;

// 实现一个将数字数组转换为字符串数组的函数
function mapNumbersToStrings(
  arr: number[]
): MapArray<typeof arr, NumberToString> {
  return arr.map((num) => String(num));
}

// 调用函数
const result = mapNumbersToStrings([1, 2, 3]);

console.log(result); // ["1", "2", "3"]

这段代码通过定义类型 MapArray 实现了对数组中每个元素的类型转换,并将转换逻辑应用于函数 mapNumbersToStrings 中,返回一个类型安全的结果。类型 StringArray 是一个元组类型 [string, string, string],表示将 [1, 2, 3] 转换为对应的字符串数组。函数中使用类型推导和断言确保返回值与类型定义一致,同时可以直接使用 StringArray 类型声明变量,保证代码的可读性和复用性。

数组累加

接下来我们应该上点难度,来实现一个数组求和的函数:

// 构造一个长度为 N 的数组
type BuildArray<
  N extends number,
  Arr extends any[] = []
> = Arr["length"] extends N ? Arr : BuildArray<N, [...Arr, 1]>;

// 辅助类型:加法
type AddNumbers<A extends number, B extends number> = [
  ...BuildArray<A>,
  ...BuildArray<B>
]["length"] extends number
  ? [...BuildArray<A>, ...BuildArray<B>]["length"]
  : never;

// 将数组中的所有数字相加
type Sum<Arr extends number[], Result extends number = 0> = Arr extends [
  infer First extends number,
  ...infer Rest extends number[]
]
  ? Sum<Rest, AddNumbers<Result, First>>
  : Result;

// 使用示例
type Result = Sum<[1, 2, 3]>; // 6

接下来我们将详细捋一下这里代码的实现逻辑;

BuildArray 构造数组它的作用是通过递归构造一个长度为 N 的数组,利用数组的 length 属性模拟数字,用数组长度表示数字的大小。

具体的实现逻辑是分为如下步骤:

  • 起始状态:Arr 默认是空数组 []。

  • 递归终止条件:Arr["length"] 等于 N 时,返回数组 Arr。

  • 递归调用:如果 Arr["length"] 不等于 N,则将 1 添加到 Arr 中并继续调用。

如下代码所示:

type ThreeArray = BuildArray<3>; // 等价于 [1, 1, 1],长度为 3
type FiveArray = BuildArray<5>; // 等价于 [1, 1, 1, 1, 1],长度为 5

20250103171935

而 AddNumbers 则是通过递归构造两个数组,然后通过数组长度来模拟加法运算。它的作用是将两个数字 A 和 B 转换为数组,通过数组的拼接模拟加法运算,拼接两个数组后,获取新数组的长度,作为加法运算的结果。

它的具体实现逻辑如下:

  • BuildArray<A>BuildArray<B> 分别生成长度为 A 和 B 的数组。

  • 使用 [...BuildArray<A>, ...BuildArray<B>] 拼接这两个数组。

  • 拼接后的数组长度(["length"])就是 A + B 的结果。

如下代码所示:

type Sum3And5 = AddNumbers<3, 5>; // 等价于 [1, 1, 1, 1, 1, 1, 1, 1]["length"],结果为 8
type Sum2And4 = AddNumbers<2, 4>; // 等价于 [1, 1, 1, 1, 1, 1]["length"],结果为 6

20250103172617

最后我们来看一下 Sum 的实现,Sum 通过递归遍历数组,它的作用是通过递归解构数组,将数组中的每个元素与 Result 相加,最终返回数组中所有元素的和。

它的具体实现逻辑如下:

  1. 分解数组:

    • 使用条件类型 Arr extends [infer First, ...infer Rest] 拆分数组,将第一个元素提取为 First,其余元素为 Rest

    • 如果数组为空(Arr[]),返回累加结果 Result

  2. 递归累加:对于数组 Arr = [1, 2, 3]:

    • 第一次递归:First = 1, Rest = [2, 3],调用 Sum<Rest, AddNumbers<Result, First>>,即 Sum<[2, 3], AddNumbers<0, 1>>

    • 第二次递归:First = 2, Rest = [3],调用 Sum<Rest, AddNumbers<Result, First>>,即 Sum<[3], AddNumbers<1, 2>>

    • 第三次递归:First = 3, Rest = [],调用 Sum<Rest, AddNumbers<Result, First>>,即 Sum<[], AddNumbers<3, 3>>

  3. 递归终止:当数组为空时(Arr = []),返回最终的 Result 值。

最终如下代码所示:

type Result1 = Sum<[1, 2, 3]>; // 等价于 AddNumbers<AddNumbers<AddNumbers<0, 1>, 2>, 3>,结果为 6
type Result2 = Sum<[4, 5, 6]>; // 等价于 AddNumbers<AddNumbers<AddNumbers<0, 4>, 5>, 6>,结果为 15

最终实现的原理总结:

  1. 通过数组长度模拟数字:使用 BuildArray<N> 构造长度为 N 的数组,通过 ["length"] 获取数字值。

  2. 通过数组拼接实现加法:AddNumbers<A, B> 拼接两个数组,并通过结果数组的长度模拟加法运算。

  3. 递归实现累加:Sum<Arr> 通过递归拆解数组,将每个元素与当前结果 Result 累加,最终返回累加值。

  4. 静态类型计算:所有计算都是在类型层面完成的,结果由 TypeScript 编译器在编译时静态推导出来。

这段代码通过巧妙地利用 TypeScript 的类型系统和递归,实现在类型层面计算数组元素的和。

中断循环

在类型体操中,我们可以使用条件类型来"中断"循环:

// 查找数组中的元素
type FindIndex<
  Arr extends any[],
  Item,
  Counter extends any[] = []
> = Arr extends [infer First, ...infer Rest]
  ? First extends Item
    ? Counter["length"]
    : FindIndex<Rest, Item, [...Counter, 1]>
  : -1;

// 使用示例
type Result = FindIndex<[1, 2, 3], 2>; // 1

这段代码通过递归拆分数组来实现对元素索引的查找:

  1. 拆分数组:Arr extends [infer First, ...infer Rest] 将数组拆分为第一个元素 First 和剩余部分 Rest,每次递归处理一个元素。

  2. 匹配目标元素:如果 First 等于目标元素 Item,返回当前计数器 Counter 的长度(Counter['length'] 即索引)。

  3. 累加计数器:如果 First 不匹配目标元素,递归处理剩余数组 Rest,并在计数器 Counter 中添加一个 1 来模拟索引递增。

  4. 递归终止:当数组为空(Arr[]),返回 -1 表示未找到目标元素。

通过这个逻辑,递归遍历数组并返回目标元素的索引,或返回 -1 表示未找到。

for 循环和 if 判断混用

在 TypeScript 类型体操中,for 循环和 if 判断可以混用,来实现更复杂的逻辑。

数组过滤

加下来我们来实现一个过滤数组:

// 过滤数组中的指定类型
type FilterArray<Arr extends any[], FilterType> = Arr extends [
  infer First,
  ...infer Rest
]
  ? First extends FilterType
    ? [First, ...FilterArray<Rest, FilterType>] // if true: 保留元素
    : FilterArray<Rest, FilterType> // if false: 跳过元素
  : [];

// 使用示例
type NumbersOnly = FilterArray<[1, "a", 2, "b", 3], number>; // [1, 2, 3]
type StringsOnly = FilterArray<[1, "a", 2, "b", 3], string>; // ['a', 'b']

如下结果所示:

20250103175640

查找满足条件的第一个元素

type FindFirst<Arr extends any[], Condition> = Arr extends [
  infer First,
  ...infer Rest
]
  ? First extends Condition
    ? First // if true: 返回找到的元素
    : FindFirst<Rest, Condition> // if false: 继续查找
  : never;

// 使用示例
type Found = FindFirst<[1, "hello", true, 2], string>; // 'hello'

如下结果所示:

20250103181101

计数满足条件的元素

type CountIf<
  Arr extends any[],
  Condition,
  Count extends any[] = []
> = Arr extends [infer First, ...infer Rest]
  ? First extends Condition
    ? CountIf<Rest, Condition, [...Count, 1]> // if true: 计数加1
    : CountIf<Rest, Condition, Count> // if false: 计数不变
  : Count["length"];

// 使用示例
type NumberCount = CountIf<[1, "a", 2, "b", 3], number>; // 3

如下结果所示:

20250103181202

分组数组元素

type Partition<
  Arr extends any[],
  Condition,
  Truthy extends any[] = [],
  Falsy extends any[] = []
> = Arr extends [infer First, ...infer Rest]
  ? First extends Condition
    ? Partition<Rest, Condition, [...Truthy, First], Falsy> // if true
    : Partition<Rest, Condition, Truthy, [...Falsy, First]> // if false
  : [Truthy, Falsy];

// 使用示例
type Split = Partition<[1, "a", 2, "b", 3], number>; // [[1, 2, 3], ['a', 'b']]

如下结果所示:

20250103181251

条件替换数组元素

type ReplaceIf<Arr extends any[], Condition, Replacement> = Arr extends [
  infer First,
  ...infer Rest
]
  ? First extends Condition
    ? [Replacement, ...ReplaceIf<Rest, Condition, Replacement>] // if true: 替换
    : [First, ...ReplaceIf<Rest, Condition, Replacement>] // if false: 保持
  : [];

// 使用示例
type Replaced = ReplaceIf<[1, "a", 2, "b", 3], number, "num">;
// ['num', 'a', 'num', 'b', 'num']

如下图示例所示:

20250103181447

这些例子展示了如何在类型体操中组合使用循环(递归)和条件判断。关键点是:

  1. 使用 extendsinfer 来进行模式匹配和类型推断

  2. 使用条件类型 (?:) 来实现分支逻辑

  3. 使用递归来实现循环

  4. 使用辅助数组来进行计数或累积结果

这些模式可以组合使用来实现更复杂的类型运算。记住要始终提供终止条件以避免无限递归。

总结

if 的实现依赖于 TypeScript 的条件类型,可以用于基于布尔值或复杂逻辑实现分支处理。for 则通过递归类型和元组操作来模拟循环逻辑,支持对数组元素或固定次数的操作。结合 if 和 for,可以实现基于条件的复杂循环逻辑,从而完成更灵活的类型处理。

最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:

如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。