TypeScript 类型体操:用纯类型系统实现大数乘法 Multiply(type-challenges 517)

原创2026-09-30 22:07:151,392 阅读
文章标签:示例工程

TypeScript 类型体操:用纯类型系统实现大数乘法 Multiply(type-challenges 517)

导读

本指南围绕 type-challenges 仓库中 00517-extreme-multiply 这一道 extreme 难度题目展开,讲解如何在 TypeScript 类型层面(不借助任何运行时计算)实现 Multiply<A, B>,让两个非负整数(支持 string、number、bigint 三种写法)相乘并返回字符串形式的精确乘积。读完本文,你将掌握模板字面量类型逐位拆解、类型级算术进位处理、竖式乘法模拟等核心技巧,并理解该题目与前置挑战 476 - Sum 之间的承接关系,最终能够独立写出通过全部测试用例的类型实现。

题目要求与挑战背景

挑战目标

00517 题目文档 的要求非常明确:实现一个类型 Multiply<A, B>,将两个非负整数相乘,并把乘积以字符串形式返回。数字参数可以写成三种形式中的任意一种:

  • 字符串字面量类型,如 '13'
  • number 字面量类型,如 2
  • bigint 字面量类型,如 321543n

对应的模板签名已在 template.ts 中给出:

type Multiply<A extends string | number | bigint, B extends string | number | bigint> = string

也就是说,实现者需要把这个返回 string 的占位类型替换为真正能计算出乘积的复杂类型。题目元数据记录在 info.yml 中:难度为 extreme,标签为 math, template-literal,并声明与 476 号题目相关。

官方示例

题目文档给出了 6 个典型示例:

type T0 = Multiply<2, 3>           // '6'
type T1 = Multiply<3, '5'>         // '15'
type T2 = Multiply<'4', 10>        // '40'
type T3 = Multiply<0, 16>          // '0'
type T4 = Multiply<'13', '21'>     // '273'
type T5 = Multiply<'43423', 321543n> // '13962361689'

从中可以看到三个关键点:

  1. 输入形式异构:number、string、bigint 可以任意混搭,类型实现必须先把它们统一归一化。
  2. 结果是字符串:哪怕是 Multiply<2, 3> 也要得到 '6' 而不是数字 6,所有类型级计算最终都要落到字符串字面量上。
  3. 支持大数:43423 × 321543 = 13962361689 是 11 位的结果,说明实现不能依赖"元组计数"这类只能处理小整数的方案,而必须模拟真正的十进制乘法。

与前置挑战 476 - Sum 的关系

题目文档开篇即强调:本挑战延续自 476 - Sum,建议先完成前者,并在此基础上修改代码。

Sum 题目 要求实现 Sum<A, B>,将两个非负整数相加并以字符串返回,同样支持 string、number、bigint 输入。它的示例包括:

type T0 = Sum<2, 3>                 // '5'
type T1 = Sum<'13', '21'>           // '34'
type T2 = Sum<'328', 7>             // '335'
type T3 = Sum<1_000_000_000_000n, '123'> // '1000000000123'

Sum 是 Multiply 的天然地基:无论是单位数相乘后的进位合并,还是竖式乘法中多组中间结果的累加,本质上都是"两个十进制字符串相加"。如果你已经实现了 Sum<A, B>,就可以把它作为内建工具直接复用到乘法实现中。

为什么类型系统里乘法如此困难

在动手实现前,先理解这道题为什么被评为 extreme 难度:

  • 类型系统没有运行时算术:2 * 3 在类型层面并不存在。number 类型在类型层面只是一个不透明的类型标记,无法读取数值大小,更不能做加减乘除。
  • 元组计数方案的局限:最朴素的"算术"手段是把数字映射为元组长度(例如 [unknown, unknown, ...] 的长度等于 3),通过 T['length'] 取数。但 4325234 × 39532 这种测试用例意味着要构造千万级长度的元组,不仅超出 TypeScript 对递归深度的合理限制,也会让编译直接卡死。因此大数题目必须另辟蹊径。
  • 一切只能靠字符串:模板字面量类型(${T}、S extends \inferF{infer F}{infer R}`` 等)是类型系统里少数能"看清内容"的载体。乘法的正确路径是把数字先变成字符串,再按十进制竖式的思路逐位运算。

实现思路剖析

以下是一套能通过测试的经典实现思路(示意代码,非仓库官方答案,仓库仅提供 template.ts 占位签名与 test-cases.ts 测试,解法由实现者自行完成)。

第一步:输入归一化

把三种输入形式统一为字符串:

type ToString<T> = T extends string ? T : `${T & string | number | bigint}`

注意 number 与 bigint 需要通过模板字面量插值转字符串;字符串输入则原样保留。归一化之后,后续所有逻辑都只面对字符串字面量。

第二步:字符串逐位拆解

利用模板字面量类型的 infer 能力,把多位数按位拆开:

type Split<S extends string> =
  S extends `${infer F}${infer R}` ? [F, ...Split<R>] : []

这样 Split<'273'> 得到 ['2', '7', '3'],每一位都是一个独立的字符字面量,后续可以单独参与类型级算术。反向顺序(从个位开始)通常更方便处理进位。

第三步:单位数字相乘与进位

0–9 与 0–9 相乘,最大为 9×9=81,结果最多两位。可以构造一个 MulDigit<A, B, Carry>,把乘积和进位都算出来,并返回 [进位, 个位] 这样的二元组。例如:

type MulDigit<A extends Chars, B extends Chars, C extends Chars = '0'> = ...
// 内部将 A、B、C 拼接为 `${A}${B}${C}` 形式的两位字符串,
// 再通过模板字面量拆成 [十位, 个位]

这一层的核心是:把数字运算"降维"成字符串拼接与匹配——A * B + C 的结果落在 0–98 区间,恰好是两位以内的十进制数,完全可以用一张"查表"式的条件分发完成,而不涉及任何数值计算。

第四步:多位数 × 单位数

有了单位乘法,就可以做竖式中的单行乘法 MulByDigit<A, D>:从个位开始逐位与 D 相乘,把上一轮产生的进位带入下一轮,最终把各轮的个位拼接起来,并在最后补上残留进位。这个操作本质上和 Sum 的逐位加法骨架一模一样,只是"加法表"换成了"乘法表"。

第五步:竖式乘法组合

多位数 × 多位数的标准做法是模拟竖式:

type Multiply<A extends string | number | bigint, B extends string | number | bigint> =
  Normalize<MultiplyStr<ToString<A>, ToString<B>>>
  • 把 B 从个位到高位逐位取出;
  • 对每一位 d 计算 A × d,得到一行中间结果;
  • 第 i 行(从 0 计数)的末尾补 i 个 '0',实现"移位";
  • 把所有行用 Sum 依次累加(进位由 Sum 内部处理)。

整个过程没有任何一步超过"单字符乘法 + 字符串加法"的规模,因此对 4325234 × 39532 这种 7 位 × 5 位的大数也能在合理编译深度内完成。

第六步:去除前导零与零值特判

竖式计算的字符串可能带有前导零(如 9999 × 1 的中间结果),最终输出前需要递归剥离 '0' 开头的部分;同时要保证纯零输入(如 0 × 16、'0' × 213、0 × '0')返回 '0' 而非空字符串。这一层可以由一个 TrimLeadingZeros<S> 工具完成,并与 S extends '0' 的特判配合。

测试用例逐条解读

测试文件 test-cases.ts 共包含 16 组断言,全部使用 Expect<Equal<...>> 形式。Expect 与 Equal 来自 workspace 依赖 @type-challenges/utils,其实现定义在 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 : false

Equal 通过比较两个函数签名的赋值方向来判断 X、Y 是否严格等价,任何多余或缺失的字面量字符都会导致断言失败——这意味着乘积字符串必须与期望值分毫不差。逐条分析测试覆盖的场景:

断言 覆盖点
Multiply<2, 3> → '6' number × number 基础运算
Multiply<3, '5'> → '15' number × string 混搭
Multiply<'4', 10> → '40' string × number 混搭
Multiply<0, 16> → '0' 零乘非零,需特判输出 '0'
Multiply<'13', '21'> → '273' 两位 × 两位,验证进位链
Multiply<'43423', 321543n> → '13962361689' bigint 输入 + 大数相乘
Multiply<9999, 1> → '9999' 乘 1 不变,验证前导零处理
Multiply<4325234, '39532'> → '170985150488' 大规模随机大数
Multiply<100_000n, '1'> → '100000' bigint 中带下划线分隔符的写法
Multiply<259, 9125385> → '2363474715' 大数 number 输入
Multiply<9, 99> → '891' 连续进位压力测试
Multiply<315, '100'> → '31500' 末尾零保留
Multiply<11n, 13n> → '143' 纯 bigint 输入
Multiply<728, 0> / Multiply<'0', 213> / Multiply<0, '0'> 三个方向的零值特判

值得注意的是 Multiply<315, '100'> → '31500':它要求类型实现既能去掉前导零,又绝不能误删末尾零,这对"字符串归一化"工具的实现精度提出了要求。

在仓库中运行与验证

本仓库采用 pnpm workspace 管理,@type-challenges/utils 以 workspace:* 协议声明在根 package.json 的 devDependencies 中,TypeScript 版本为 ^5.3.3。验证方式依托 TypeScript 编译器对 test-cases.ts 做静态类型检查:每当 Multiply 的实际结果与断言期望值不严格等价时,Equal 计算出的布尔值不是 true,Expect<T extends true> 的约束即告失败,tsc 就会报错。因此,只要类型检查通过,就意味着 16 组乘法断言全部成立。

实现者可以在仓库根目录执行依赖安装(pnpm install),然后将自己的实现写入 template.ts,再运行 TypeScript 编译器检查 test-cases.ts 是否无错误。作为参考,同目录下其他已完成题目的 template.ts 与 test-cases.ts 结构完全一致,可用于对照仓库的组织规范。

小结

Multiply 是 type-challenges 数学类题目中最具代表性的 extreme 挑战之一。它的难点不在"乘法"本身,而在于把十进制竖式算法完整地翻译到类型层面:输入归一化、模板字面量逐位拆解、单位乘法进位表、Sum 复用与多行累加、前导零清理。掌握这套"字符串算术"范式后,你不仅能拿下 476 - Sum 与 517 两道题,也能为后续更复杂的类型级数值运算(如减法、比较、进制转换类题目)打下坚实基础。

登录后查看全文
type-challenges