Typescript进阶之类型体操套路四

套路四:数组长度做计数

类型系统不是图灵完备,各种逻辑都能写么,但好像没发现数值相关的逻辑。

没错,数值相关的逻辑比较绕,被我单独摘了出来,就是这节要讲的内容。

这是类型体操的第四个套路:数组长度做计数。

数组长度做计数

TypeScript 类型系统没有加减乘除运算符,怎么做数值运算呢?

不知道大家有没有注意到数组类型取 length 就是数值。

比如:

而数组类型我们是能构造出来的,那么通过构造不同长度的数组然后取 length,不就是数值的运算么?

TypeScript 类型系统中没有加减乘除运算符,但是可以通过构造不同的数组然后取 length 的方式来完成数值计算,把数值的加减乘除转化为对数组的提取和构造。

(严格来说构造的是元组,大家知道数组和元组的区别就行)

这点可以说是类型体操中最麻烦的一个点,需要思维做一些转换,绕过这个弯来。

数组长度实现加减乘除

Add

我们知道了数值计算要转换为对数组类型的操作,那么加法的实现很容易想到:

构造两个数组,然后合并成一个,取 length。

比如 3 + 2,就是构造一个长度为 3 的数组类型,再构造一个长度为 2 的数组类型,然后合并成一个数组,取 length。

构造多长的数组是不确定的,需要递归构造,这个我们实现过:

typescript 复制代码
type BuildArray<
    Length extends number, 
    Ele = unknown, 
    Arr extends unknown[] = []
> = Arr['length'] extends Length 
        ? Arr 
        : BuildArray<Length, Ele, [...Arr, Ele]>;
​

解释

  1. 泛型参数 Length extends number, Ele = unknown, Arr extends unknown[] = []

    • Length 是一个泛型参数,表示目标数组的长度,必须是一个数字。
    • Ele 是一个可选的泛型参数,默认值为 unknown,表示数组中每个元素的类型。
    • Arr 是一个可选的泛型参数,默认值为 [],表示当前构建的数组。
  2. 条件类型 Arr['length'] extends Length

    • 检查当前构建的数组 Arr 的长度是否等于目标长度 Length
    • Arr['length'] 获取数组 Arr 的长度。
  3. 递归处理 BuildArray<Length, Ele, [...Arr, Ele]>

    • 如果当前数组的长度还没有达到目标长度,则递归调用 BuildArray,并向当前数组 Arr 中添加一个新元素 Ele
    • 新数组的长度会增加 1。
  4. 默认情况 ? Arr

    • 如果当前数组的长度已经等于目标长度,则返回当前数组 Arr

类型参数 Length 是要构造的数组的长度。类型参数 Ele 是数组元素,默认为 unknown。类型参数 Arr 为构造出的数组,默认是 \[\]。

如果 Arr 的长度到达了 Length,就返回构造出的 Arr,否则继续递归构造。

构造数组实现了,那么基于它就能实现加法:

scala 复制代码
type Add<Num1 extends number, Num2 extends number> = 
    [...BuildArray<Num1>,...BuildArray<Num2>]['length'];
​
go 复制代码
// 示例 1: 计算 2 + 3
type Sum1 = Add<2, 3>;
// 结果:5

// 示例 2: 计算 0 + 0
type Sum2 = Add<0, 0>;
// 结果:0
ini 复制代码
// 示例 1: 构建长度为 3 的数组,元素类型为 `unknown`
type Array1 = BuildArray<3>;
// 结果:[unknown, unknown, unknown]

// 示例 2: 构建长度为 3 的数组,元素类型为 `string`
type Array2 = BuildArray<3, string>;
// 结果:[string, string, string]

Subtract

加法是构造数组,那减法怎么做呢?

减法是从数值中去掉一部分,很容易想到可以通过数组类型的提取来做。

比如 3 是 unknown, unknown, unknown 的数组类型,提取出 2 个元素之后,剩下的数组再取 length 就是 1。

所以减法的实现是这样的:

scala 复制代码
type Subtract<Num1 extends number, Num2 extends number> = 
    BuildArray<Num1> extends [...arr1: BuildArray<Num2>, ...arr2: infer Rest]
        ? Rest['length']
        : never;
​

解释

  1. 泛型参数 Num1 extends number, Num2 extends number

    • Num1Num2 是泛型参数,表示传入的类型必须是数字。
  2. 使用 BuildArray 创建数组

    • BuildArray<Num1> 创建一个长度为 Num1 的数组。
    • BuildArray<Num2> 创建一个长度为 Num2 的数组。
  3. 模式匹配 BuildArray<Num1> extends [...arr1: BuildArray<Num2>, ...arr2: infer Rest]

    • 使用模式匹配来检查 BuildArray<Num1> 是否可以表示为 [...arr1: BuildArray<Num2>, ...arr2: infer Rest]
    • arr1 是长度为 Num2 的数组。
    • Rest 是剩余的数组部分。
  4. 获取剩余数组的长度

    • 如果模式匹配成功,则 Rest 是从 BuildArray<Num1> 中移除 BuildArray<Num2> 后的剩余数组。
    • Rest['length'] 获取剩余数组的长度,这个长度就是 Num1 减去 Num2 的结果。
  5. 默认情况 : never

    • 如果模式匹配失败(即 Num1 小于 Num2),则返回 never 类型。

类型参数 Num1、Num2 分别是被减数和减数,通过 extends 约束为 number。

构造 Num1 长度的数组,通过模式匹配提取出 Num2 长度个元素,剩下的放到 infer 声明的局部变量 Rest 里。

取 Rest 的长度返回,就是减法的结果。

go 复制代码
// 示例 1: 计算 5 - 2
type Diff1 = Subtract<5, 2>;
// 结果:3

// 示例 2: 计算 3 - 3
type Diff2 = Subtract<3, 3>;
// 结果:0
  • Num15Num22
  • BuildArray<5> 创建一个长度为 5 的数组 [unknown, unknown, unknown, unknown, unknown]
  • BuildArray<2> 创建一个长度为 2 的数组 [unknown, unknown]
  • 模式匹配成功,剩余数组是 [unknown, unknown, unknown],长度为 3

Multiply

我们把加法转换为了数组构造,把减法转换为了数组提取。那乘法怎么做呢?

为了解释乘法,我去翻了下小学教材,找到了这样一张图:

1 乘以 5 就相当于 1 + 1 + 1 + 1 + 1,也就是说乘法就是多个加法结果的累加。

那么我们在加法的基础上,多加一个参数来传递中间结果的数组,算完之后再取一次 length 就能实现乘法:

typescript 复制代码
type Mutiply<
    Num1 extends number,
    Num2 extends number,
    ResultArr extends unknown[] = []
> = Num2 extends 0 ? ResultArr['length']
        : Mutiply<Num1, Subtract<Num2, 1>, [...BuildArray<Num1>, ...ResultArr]>;
​

类型参数 Num1 和 Num2 分别是被加数和加数。

因为乘法是多个加法结果的累加,我们加了一个类型参数 ResultArr 来保存中间结果,默认值是 \[\],相当于从 0 开始加。

每加一次就把 Num2 减一,直到 Num2 为 0,就代表加完了。

加的过程就是往 ResultArr 数组中放 Num1 个元素。

这样递归的进行累加,也就是递归的往 ResultArr 中放元素。

最后取 ResultArr 的 length 就是乘法的结果。

解释

  1. 泛型参数 Num1 extends number, Num2 extends number, ResultArr extends unknown[] = []

    • Num1Num2 是泛型参数,表示传入的类型必须是数字。
    • ResultArr 是一个可选的泛型参数,默认值为 [],表示当前的结果数组。
  2. 条件类型 Num2 extends 0

    • 检查 Num2 是否为 0。
    • 如果 Num2 为 0,则返回结果数组的长度 ResultArr['length'],这就是最终的乘积。
  3. 递归处理 Multiply<Num1, Subtract<Num2, 1>, [...BuildArray<Num1>, ...ResultArr]>

    • 如果 Num2 不为 0,则递归调用 Multiply,将 Num2 减 1。
    • Num1 对应的数组 BuildArray<Num1> 添加到当前的结果数组 ResultArr 中。
  4. 使用 Subtract 类型别名

    • Subtract<Num2, 1> 用于计算 Num2 - 1
go 复制代码
// 示例 1: 计算 3 * 2
type Product1 = Multiply<3, 2>;
// 结果:6
  • Num13Num22
  • 初始 ResultArr[]
  • 第一次递归:Num22,不为 0,调用 Multiply<3, 1, [unknown, unknown, unknown]>
  • 第二次递归:Num21,不为 0,调用 Multiply<3, 0, [unknown, unknown, unknown, unknown, unknown, unknown]>
  • 第三次递归:Num20,返回 ResultArr 的长度 6

Divide

乘法是递归的累加,那除法不就是递归的累减么?

我再去翻了下小学教材,找到了这样一张图:

我们有 9 个苹果,分给美羊羊 3 个,分给懒羊羊 3 个,分给沸羊羊 3 个,最后剩下 0 个。所以 9 / 3 = 3。

所以,除法的实现就是被减数不断减去减数,直到减为 0,记录减了几次就是结果。

也就是这样的:

typescript 复制代码
type Divide<
    Num1 extends number,
    Num2 extends number,
    CountArr extends unknown[] = []
> = Num1 extends 0 ? CountArr['length']
        : Divide<Subtract<Num1, Num2>, Num2, [unknown, ...CountArr]>;
​

解释

  1. 泛型参数 Num1 extends number, Num2 extends number, CountArr extends unknown[] = []

    • Num1Num2 是泛型参数,表示传入的类型必须是数字。
    • CountArr 是一个可选的泛型参数,默认值为 [],表示当前的计数器数组。
  2. 条件类型 Num1 extends 0

    • 检查 Num1 是否为 0。
    • 如果 Num1 为 0,则返回计数器数组的长度 CountArr['length'],这就是最终的商。
  3. 递归处理 Divide<Subtract<Num1, Num2>, Num2, [unknown, ...CountArr]>

    • 如果 Num1 不为 0,则递归调用 Divide,将 Num1 减去 Num2
    • unknown 添加到当前的计数器数组 CountArr 中。
  4. 使用 Subtract 类型别名

    • Subtract<Num1, Num2> 用于计算 Num1 - Num2

类型参数 Num1 和 Num2 分别是被减数和减数。

类型参数 CountArr 是用来记录减了几次的累加数组。

如果 Num1 减到了 0 ,那么这时候减了几次就是除法结果,也就是 CountArr'length'

否则继续递归的减,让 Num1 减去 Num2,并且 CountArr 多加一个元素代表又减了一次。

这样就实现了除法:

go 复制代码
// 示例 1: 计算 6 / 2
type Quotient1 = Divide<6, 2>;
// 结果:3

// 示例 2: 计算 8 / 3
type Quotient2 = Divide<8, 3>;
// 结果:2
  • Num16Num22
  • 初始 CountArr[]
  • 第一次递归:Num16,不为 0,调用 Divide<4, 2, [unknown]>
  • 第二次递归:Num14,不为 0,调用 Divide<2, 2, [unknown, unknown]>
  • 第三次递归:Num12,不为 0,调用 Divide<0, 2, [unknown, unknown, unknown]>
  • 第四次递归:Num10,返回 CountArr 的长度 3

数组长度实现计数

StrLen

数组长度可以取 length 来得到,但是字符串类型不能取 length,所以我们来实现一个求字符串长度的高级类型。

字符串长度不确定,明显要用递归。每次取一个并计数,直到取完,就是字符串长度。

typescript 复制代码
type StrLen<
    Str extends string,
    CountArr extends unknown[] = []
> = Str extends `${string}${infer Rest}` 
    ? StrLen<Rest, [...CountArr, unknown]> 
    : CountArr['length']
​

解释

  1. 泛型参数 Str extends string, CountArr extends unknown[] = []

    • Str 是一个泛型参数,表示传入的类型必须是字符串。
    • CountArr 是一个可选的泛型参数,默认值为 [],表示当前的计数器数组。
  2. 条件类型 Str extends {string}${infer Rest}`

    • 检查 Str 是否可以表示为 ${string}${infer Rest},其中 Rest 是剩余的字符串部分。
    • infer Rest 用于推断剩余的字符串部分。
  3. 递归处理 StrLen<Rest, [...CountArr, unknown]>

    • 如果 Str 不为空,则递归调用 StrLen,将剩余的字符串 Rest 传递给下一次递归。
    • unknown 添加到当前的计数器数组 CountArr 中。
  4. 默认情况 : CountArr['length']

    • 如果 Str 为空,则返回计数器数组的长度 CountArr['length'],这就是最终的字符串长度。

类型参数 Str 是待处理的字符串。类型参数 CountArr 是做计数的数组,默认值 \[\] 代表从 0 开始。

每次通过模式匹配提取去掉一个字符之后的剩余字符串,并且往计数数组里多放入一个元素。递归进行取字符和计数。

如果模式匹配不满足,代表计数结束,返回计数数组的长度 CountArr'length'

这样就能求出字符串长度:

go 复制代码
// 示例 1: 计算 "abc" 的长度
type Len2 = StrLen<'abc'>;
// 结果:3
  • Str"abc"
  • 初始 CountArr[]
  • 第一次递归:Str"abc",不为空,调用 StrLen<'bc', [unknown]>
  • 第二次递归:Str"bc",不为空,调用 StrLen<'c', [unknown, unknown]>
  • 第三次递归:Str"c",不为空,调用 StrLen<'', [unknown, unknown, unknown]>
  • 第四次递归:Str'',返回 CountArr 的长度 3

总结

TypeScript 类型系统没有加减乘除运算符,所以我们通过数组类型的构造和提取,然后取长度的方式来实现数值运算

我们通过构造和提取数组类型实现了加减乘除,也实现了各种计数逻辑。

用数组长度做计数这一点是 TypeScript 类型体操中最麻烦的一个点,也是最容易让新手困惑的一个点。

相关推荐
AlienZHOU3 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc7 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师8 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen8 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒9 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow9 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8189 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800789 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明9 小时前
第 6 章 组件进阶
前端·vue.js
江华森9 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端