- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
导读
本篇文章围绕 type-challenges 仓库中编号 07561 的极端(extreme)难度题目Subtract展开,讲解如何仅靠 TypeScript 类型系统、借助 "用元组长度映射数字"(BuildTuple)这一经典手段实现类型层面的整数减法,并正确处理"被减数小于减数时返回never"的边界规则。读完本文你将掌握基于元组(tuple)长度做类型级算术的核心套路、读懂该题目配套模板与测试用例的约束,并理解这类"简单版"实现为什么在数字变大时会触碰 TypeScript 递归实例化深度限制。
一、题目概览:一道专注"简单减法"的 extreme 题目
Subtract 是 type-challenges 中的一道extreme(极端)难度题目,其元数据(见 info.yml)标注:
- 难度:
extreme - 标签:
tuple(元组) - 作者:Lo(GitHub: @LoTwT)
题目出自 README.md,核心要求只有一句话:
Implement the type Subtraction that is
-in Javascript by using BuildTuple.
即在类型层面实现 JavaScript 的减法语义(-运算符),且解题路线被明确指定为BuildTuple——先构造元组,再通过元组长度完成数值运算。题目同时强调了两点约束:
- If the minuend is less than the subtrahend, it should be
never.当被减数小于减数(即结果为负数)时,返回never而非负数; - It's a simple version.题目自称为"简单版",暗示该实现并不追求覆盖任意大整数,这在配套测试用例中也有体现(详见下文第四节)。
二、需求规格拆解:输入、输出与边界
题目给出的示例非常简洁:
Subtract<2, 1> // expect to be 1 Subtract<1, 2> // expect to be never结合模板与测试,可以将行为规格完整归纳为一张表:
| 表达式 | 结果 | 含义 |
|---|---|---|
Subtract<M, S>且 M > S | M - S | 正常减法 |
Subtract<M, S>且 M === S | 0 | 相等时结果为 0 |
Subtract<M, S>且 M < S | never | 结果为负,直接否决 |
其中两个类型参数的命名在模板中已有注释(见 template.ts):
// M => minuend, S => subtrahend type Subtract<M extends number, S extends number> = anyM即minuend(被减数);S即subtrahend(减数);- 两个参数均被约束为
number,模板默认实现为any,等待解题者替换。
之所以要求"小于时返回never",是因为类型系统没有"负数"这一原生表示,用元组长度无法直接表达负数结果,因此用never显式拒绝该输入组合,避免产生错误推断。
三、测试驱动:从 test-cases.ts 读懂验收标准
配套测试用例位于 test-cases.ts,全文如下:
import type { Equal, Expect } from '@type-challenges/utils' type cases = [ Expect<Equal<Subtract<1, 1>, 0>>, Expect<Equal<Subtract<2, 1>, 1>>, Expect<Equal<Subtract<1, 2>, never>>, // @ts-expect-error Expect<Equal<Subtract<1000, 999>, 1>>, ]逐行解读验收标准:
Subtract<1, 1>必须精确等于0:相等输入的处理是必测项,说明实现不能只覆盖"大减小";Subtract<2, 1>必须精确等于1:最基础的正常减法;Subtract<1, 2>必须精确等于never:验证"被减数小于减数"时返回never的规则;@ts-expect-error标注的Subtract<1000, 999>:这是"简单版"最重要的证据。@ts-expect-error的含义是"下一行预期会产生类型错误"——由于1000规模的元组递归构造会触碰 TypeScript 的实例化深度限制,这行求值会报错,因此被测试作者用该指令"豁免",避免Expect断言误伤整个用例集。
测试中使用的Equal/Expect工具类型来自仓库的 utils/index.d.ts:
export type Expect<T extends true> = T export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : falseExpect要求传入的必须是字面量true;Equal采用"函数签名比较"的经典技巧,能够区分1与number这类宽窄类型,从而保证减法结果必须是精确的字面量类型而非number。
四、核心原理:用 BuildTuple 把数字"翻译"成元组长度
题目指定使用 BuildTuple 解法。其核心思想是类型系统的两个已知事实:
- 元组的
length属性是字面量数字类型,例如[unknown, unknown]['length']精确等于2; - 通过递归拼接元组,可以把任意数字
N映射为长度恰为N的元组,从而让类型系统"数数"。
仓库中同作者(同为 @LoTwT)的另一道 medium 题目 Construct Tuple(07544) 正是这一能力的直接练习题,其要求为:
type result = ConstructTuple<2> // expect to be [unknown, unknown]Subtract 与 Construct Tuple 一脉相承:减法可以转换为"从长度为 M 的元组中移除 S 个元素,剩下元组的长度即为M - S"。
BuildTuple 的递归构造
一个标准的 BuildTuple 实现如下(供理解参考,非仓库模板内容):
type BuildTuple<L extends number, T extends unknown[] = []> = T['length'] extends L ? T : BuildTuple<L, [...T, unknown]>执行过程可以这样理解:
- 从空元组
[]开始; - 每次递归追加一个
unknown元素,即[...T, unknown]; - 当
T['length']与目标L相等时终止递归并返回T。
例如BuildTuple<2>会依次经历[]→[unknown]→[unknown, unknown],最终得到长度为2的元组。
用模式匹配做"减法"
有了 BuildTuple,减法可以通过元组解构(variadic tuple types)完成:先分别构造BuildTuple<M>与BuildTuple<S>,再用[...BuildTuple<S>, ...infer R]去匹配长元组,剩余部分R的长度就是差值:
type BuildTuple<L extends number, T extends unknown[] = []> = T['length'] extends L ? T : BuildTuple<L, [...T, unknown]> type Subtract<M extends number, S extends number> = M extends S ? 0 : BuildTuple<M> extends [...BuildTuple<S>, ...infer R] ? R['length'] : never逻辑分支说明:
M extends S ? 0:先处理相等情形,Subtract<1, 1>直接命中0,避免走元组构造;BuildTuple<M> extends [...BuildTuple<S>, ...infer R]:尝试从长元组前段"剥掉" S 个元素;- 若匹配成功,说明
M >= S,剩余R['length']即为差值,如Subtract<2, 1>→[unknown]的length为1; - 若匹配失败(M < S),说明减数元组比被减数元组还长,永远无法匹配,落入
never分支,如Subtract<1, 2>→never。
- 若匹配成功,说明
五、"简单版"的边界:递归深度限制的证据
题目 README 强调 "It's a simple version",这一声明在测试用例中得到印证:Expect<Equal<Subtract<1000, 999>, 1>>被@ts-expect-error包裹。
从源码结构可以推断:BuildTuple 采用逐元素递归构造,Subtract<1000, 999>需要先递归生成长度 1000 的元组,而 TypeScript 对类型实例化深度有硬性限制。当递归层级超过该限制时,编译器会直接报 "Type instantiation is excessively deep and possibly infinite" 一类的错误,Equal断言自然无法成立。测试作者因此用@ts-expect-error将其显式豁免——既保留了"大数场景下求值会报错"这一事实的可见性,又不会让整个用例集失败。
这带来的实际约束是:
- 本实现适合
M、S在几十以内的小整数场景; - 若要支持
1000及以上规模的数字,需要切换到"按位/字符串逐位计算"等更复杂的方案,例如仓库中 Sum(00476)、Integers Comparator(00274) 等 extreme 题目所采用的 template-literal + 逐位进位策略。
六、如何本地验证你的实现
type-challenges 的题目采用"类型检查即测试"的模式,无需运行 JavaScript:
- 将你的实现写入 template.ts 中的
Subtract类型(替换默认的any); - 使用 TypeScript 编译器对 test-cases.ts 做类型检查(如
tsc --noEmit); - 若所有
Expect<Equal<...>>均成立,说明实现通过验收;若某个Equal不成立,Expect<T extends true>会给出类型错误提示。
测试文件依赖@type-challenges/utils提供的Equal/Expect(定义见 utils/index.d.ts),这也是 type-challenges 全部题目共用的验收工具集。
七、延伸阅读:仓库中的"元组与数字"题目家族
Subtract 并非孤立存在,围绕"用元组/字符串做类型级算术",仓库中还有一系列可对照学习的题目:
| 题目 | 难度 | 技术要点 |
|---|---|---|
| Construct Tuple(07544) | medium | 用元组长度映射数字,Subtract 的 BuildTuple 前置技能 |
| MinusOne(02257) | medium | 数字减一,同样依赖元组长度递减 |
| Sum(00476) | extreme | 支持大数与 bigint 的加法,采用逐位计算规避递归深度限制 |
| Integers Comparator(00274) | extreme | 支持负数与零的整数比较器 |
对比可见:以元组长度为"算盘"的简单方案(Subtract、MinusOne)实现直观、易于理解,但受递归深度限制;而以字符串逐位运算的方案(Sum、Integers Comparator)复杂度更高,却能突破数字规模的上限。理解 Subtract,正是踏入 type-challenges 类型级算术世界的第一步。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 极端难度题解:用 TypeScript 类型系统实现 JSON Parser(06228)
type challenges 极端难度题解:用 TypeScript 类型系统实现 JSON Parser(06228) type challenges 仓库
示例工程两数之和(Two Sum)类型级实现:深度拆解 type-challenges 困难题 08804
两数之和(Two Sum)类型级实现:深度拆解 type challenges 困难题 08804 本文围绕 questions/08804 hard two
示例工程type-challenges 实战:在类型系统中实现大整数加法 Sum\<A, B\>(extreme 难度)
type challenges 实战:在类型系统中实现大整数加法 Sum\<A, B\ (extreme 难度) 本文围绕 type challenges 第
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考