# 进阶-计数-加减乘除
TS 的类型系统本身不支持数值运算 —— 1 + 1 在类型层不会得到 2。所有"类型数学"都要借助元组长度绕一圈。本文把加、减、乘、除、大小比较全部串起来。
# 核心工具:T['length']
元组类型的 length 属性,在类型层能直接拿到字面量 number:
type A = [any, any, any]['length']; // 3
type B = []['length']; // 0
这是把"元组形态"翻译回"数值形态"的唯一通路,所有算术题最终都要用到它。
# 构造指定长度的元组
反向操作:给一个 number N,造一个长度为 N 的元组。递归模板:
type BuildTuple<N extends number, R extends any[] = []> = R['length'] extends N
? R
: BuildTuple<N, [...R, any]>;
type T = BuildTuple<5>; // [any, any, any, any, any]
原理:不断往 R 里 push,直到 R['length'] 等于 N。这是之后做加减乘除的"数值 → 元组"转换器。
# 加法
把两个元组拼起来,长度就是和:
type Add<A extends number, B extends number> = [
...BuildTuple<A>,
...BuildTuple<B>,
]['length'];
type R = Add<2, 3>; // 5
够用,但递归深度受限(一次 BuildTuple 每 1 递归一次)。
# 减法
减法是"在长的里面拆掉短的长度":
type Sub<A extends number, B extends number> = BuildTuple<A> extends [
...BuildTuple<B>,
...infer R,
]
? R['length']
: never;
type R = Sub<5, 2>; // 3
如果 A < B,模式匹配失败返回 never;实际做题时可以把这个语义当成"下界为 0 的减法"。
# 比较大小
基于减法,就能做大小比较 —— 如果 A - B 成立则 A >= B:
type GTE<A extends number, B extends number> = BuildTuple<A> extends [
...BuildTuple<B>,
...any,
]
? true
: false;
type GT<A extends number, B extends number> = A extends B ? false : GTE<A, B>;
medium/4425-实现比较 就是这个套路。
另一种写法是"同时 +1 向前跑,看谁先到",在处理 hard/extreme 的整数比较器时会碰到,见 extreme/274-整数比较器。
# 乘法
乘法是"加 B 次 A":
type Multi<
A extends number,
B extends number,
R extends any[] = [],
> = B extends 0
? R['length']
: Multi<A, Sub<B, 1> & number, [...R, ...BuildTuple<A>]>;
因为每乘一次要用 BuildTuple<A>,递归树会比较深,数值稍大就爆栈。实践中乘法题目(medium/extreme 都有)基本都是小数值。
一种更简洁但仍会爆栈的写法:
type Multi<
A extends number,
B extends number,
R extends any[] = [],
> = R['length'] extends B
? Flatten<R>['length'] // R 是 [A, A, ..., A] B 个,展开后长度就是 A*B
: Multi<A, B, [...R, BuildTuple<A>]>;
# 除法
除法是"看 A 能减几次 B":
type Div<
A extends number,
B extends number,
C extends any[] = [],
> = BuildTuple<A> extends [...BuildTuple<B>, ...infer R]
? Div<R['length'] & number, B, [...C, 1]>
: C['length'];
type R = Div<10, 3>; // 3
同样只处理整数,余数靠减法算。
# 递归深度红线
上面所有操作本质都是 [...A, B] 的元组递归。TS 对递归有硬限制:
- TS 4.5 之前:尾递归 50 层;
- TS 4.5+:1000 层(启用 tail-recursion 优化后)。
所以:
- 数值不能太大;
- BuildTuple 要写成尾递归形式(累加器在参数位置);
- 计数时每递归一次只 push 1 个元素,不要写成指数级。
更多讨论见 递归深度。
# 计数器模式的通用性
元组长度计数不只用来算"数学",更常用的场景是给递归"编号":
// 数一个字符串里有多少个 'a'
type CountA<
S extends string,
C extends any[] = [],
> = S extends `${infer F}${infer R}`
? F extends 'a'
? CountA<R, [...C, 1]>
: CountA<R, C>
: C['length'];
type N = CountA<'banana'>; // 3
凡是要"在递归里记某件事发生了几次""记当前是第几项"的场合,辅助元组都派得上用场。
# 字符串 ↔ 数字
有时题目给你字符串形式的数字(比如 "123"),要在类型层做数学,需要先把它变回数字:
type ToNum<S extends string> = S extends `${infer N extends number}`
? N
: never;
type A = ToNum<'42'>; // 42
反过来数字转字符串直接模板:
type N = 42;
type S = `${N}`; // '42'
带运算的字符串数字题(如 medium/10969-整数)都会混合使用这些操作。
# 速查表
| 运算 | 模板骨架 |
|---|---|
N → tuple | BuildTuple<N>:递归 push 到 length === N |
tuple → N | T['length'] |
A + B | [...BuildTuple<A>, ...BuildTuple<B>]['length'] |
A - B | BuildTuple<A> extends [...BuildTuple<B>, ...infer R] ? R['length'] : never |
A * B | 递归:每轮把 BuildTuple<A> 拼进累加器 |
A / B | 递归:每轮从 A 里削掉一个 BuildTuple<B>,计数 |
A >= B | BuildTuple<A> extends [...BuildTuple<B>, ...any] ? true : false |
# 被谁用到
- 减 / 加:medium/2257-减一, medium/4182-斐波那契序列, extreme/476-Sum。
- 大小比较:medium/4425-实现比较, hard/9384-Maximum, extreme/274-整数比较器。
- 计数:medium/298-计算字符的长度, medium/9142-CheckRepeatedChars, hard/651-字符长度 2。
- 构造定长元组:medium/7544-构造一个给定长度的元组, medium/4518-fill。
- 字符串整数:medium/10969-整数, hard/6141-二进制转十进制。
看到"类型层的数字运算"反射这一套模板就行,剩下就是控好递归深度。
← 基操-元组遍历的黑科技 进阶-逆变顺变协变 →