第一次看到有人用 TypeScript 的类型系统解数独,我的第一反应是:这人是认真在写代码,还是单纯想折磨 TypeScript 编译器?
那堆代码里没有任何 runtime 逻辑,全是 type、infer、extends、递归条件类型。我在编辑器里 hover 上去,发现编译期居然真的把残缺的九宫格一步步填满了。为了彻底搞懂它,我把整段代码丢给 DeepSeek,让它逐行讲、反复问,最后整理出了下面这套从零开始的解读。它不只是一次炫技,更是一次对 TypeScript 类型机制的极限训练。这篇内容适合写过类型体操、维护过复杂类型定义,或者单纯想看看"类型系统到底能跑多快"的人。
1. "类型即程序":这算哪门子数独求解?
先别急着看代码,把概念理顺:当我们说"用 TypeScript 的类型求解数独",到底是在哪一层"运行"?
普通程序跑在 JavaScript 引擎里,值是字符串、数字、对象。而类型程序跑在 tsc 里,输入是类型,输出还是类型。你可以把编译器当作一台微型虚拟机,type 就是它的指令集,条件类型就是它的 if,递归类型就是循环,infer 就是模式匹配。数独求解本质上是搜索和回溯,这种算法天然可以用递归来表达,而 TypeScript 的条件类型又支持递归,所以理论上任何可计算问题都能塞进类型系统。关键不是"能不能",而是"怎么写才不会被编译器卡死"。
这段代码之所以让人看得头皮发麻,是因为它把类型系统的一切表达力都压在了一张九宫格上:
- 模板字面量类型用来编码棋盘坐标。
- 递归条件类型用来遍历空格、尝试数字。
- 分布式条件类型用来模拟"对一个数字集合做分支尝试"。
never用来表示"此路不通"。infer用来从大类型里拆出子类型。
很多人把这类代码当成"类型体操表演赛",但它其实有实际价值。比如写表单 schema 校验时,你可以把规则直接建模到类型层面,让非法数据在编译期就暴露;写数据库驱动时,SQL 语句的拼接结果可以完全由类型推导出来。理解了类型级数独,回头看这些场景会轻松很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把普通解法翻译成类型:从数组回溯到递归条件类型
2.1 普通回溯算法长什么样
一道标准数独的解法,通常是这么写的:
- 找到第一个空白格。
- 依次尝试填入
1~9。 - 每填一个数字,检查行、列、宫有没有冲突。
- 没有冲突就递归进入下一个空格。
- 如果 9 个数字都试完仍然走不通,就回溯到上一格,换一个数字继续。
这是一个教科书式的深度优先搜索。如果你把"棋盘数组"换成"棋盘类型",把"数组索引"换成"模板字面量拼接出来的坐标键",把"循环"换成"递归条件类型",整个算法就能原封不动地搬进类型系统。
2.2 值空间和类型空间的映射关系
我先用一张表格把对应的关系列出来,阅读理解整个代码时会轻松很多:
| 普通解法 | 类型空间里的对应物 |
|---|---|
棋盘数组 string[][] |
Board extends Record<CellKey, Digit | '.'> |
空白格坐标 [row, col] |
字符串键 '00' '45' 等 |
| 遍历找空格 | 用 infer 拆解棋盘对象的键 |
| 检查行/列/宫冲突 | UsedInRow、UsedInCol、UsedInBox 三个条件类型 |
| 尝试 1~9 | 分布式条件类型 C extends Digit,自动展开联合类型 |
| 回溯失败 | 递归分支最终返回 never |
表格看着简单,但实际代码里的每一步都要比普通数组操作繁琐十倍,因为类型系统没有"循环变量",一切都要通过类型间的运算来表达。
2.3 一个最小例子:检查某行是否有重复数字
比如要检查某一行里是否已经存在数字 N,普通代码是 row.includes(N)。类型代码就得先把这一行所有格子里的数字取出,合并成一个联合类型,再判断 N 是否属于这个联合类型。
typescript复制type RowKey = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
type CellKey = `${RowKey}${RowKey}`;
type Digit = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Board = { [K in CellKey]: Digit | '.' };
// 生成同一行所有格子键的辅助类型
type KeysInRow<R extends RowKey> = `${R}${RowKey}`;
// 提取一行里的所有数字,把 '.' 过滤掉
type UsedInRow<R extends RowKey, B extends Board> =
KeysInRow<R> extends infer K
? K extends CellKey
? B[K] extends Digit
? B[K]
: never
: never
: never;
// 判断数字 N 是否已在某行中出现
type IsInRow<R extends RowKey, B extends Board, N extends Digit> =
N extends UsedInRow<R, B> ? true : false;
注意我直接用了 extends infer K 再配合 K extends CellKey 的方式强制展开键。KeysInRow 本身是 '0' | '1' | ... | '8' 和另一组 RowKey 做模板拼接的结果,天然是一个联合类型。当 B[K] 拿到每个格子的值时,如果它是 '.' 就返回 never,相当于在联合类型里"过滤"掉它。这里能看到一个核心技巧:联合类型配合分布式条件类型,可以模拟 filter。这个思路贯穿类型数独的整个实现。
3. 类型版数独的"数据结构"怎么搭:棋盘、行列坐标与候选值编码
3.1 棋盘表达方式的选择
写类型代码前,最先要定的是棋盘类型长什么样。我见过三种常见方案,各有取舍:
| 方案 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| 对象棋盘 | Record<CellKey, Digit | '.'> |
键直观,易更新 | 需要大量模板字面量操作 |
| 元组棋盘 | [ [1,2,...], ... ] |
接近运行时数组 | 更新、索引很痛苦 |
| 字符串棋盘 | '530070000600195000...' |
输入输入方便 | 每次访问都要拆字符串,效率低 |
做数独求解时,最合适的组合是:对外用字符串,内部转成对象棋盘。字符串负责定义可读的盘面输入,对象棋盘负责计算。
typescript复制type CellKey = `${RowKey}${RowKey}`;
type Board = { [K in CellKey]: Digit | '.' };
这样每个格子都通过类似 '00'、'35' 这样的字符串键来访问。把行、列都限制在 '0' | ... | '8',模板字面量类型会自动生成 81 个键,不需要手写。
3.2 坐标与区域的编码
'35' 这个键本身携带了行和列信息:第一个字符是行。提取行、列、宫时,可以用模板字面量推断:
typescript复制type RowOf<K extends CellKey> = K extends `${infer R}${string}` ? R : never;
type ColOf<K extends CellKey> = K extends `${string}${infer C}` ? C : never;
宫序号就要稍微绕一下。一个格子 (r, c) 属于第 b = floor(r / 3) * 3 + floor(c / 3) 个宫。但类型层面没有 / 和 floor,所以更简单的做法是维护一个从 RowKey 映射到"宫组行"的常量表,用条件类型逐个判断。
typescript复制type RowToBoxRow<R extends RowKey> =
R extends '0' | '1' | '2' ? '0' :
R extends '3' | '4' | '5' ? '1' :
'2';
类似地,ColToBoxCol 也有三个分支。然后用两者拼接出宫格内的格子键集合。这种做法看起来像手写,但比在类型里实现除法快得多,也不容易出错。
3.3 更新棋盘的 Fill 类型
求解过程中最常用的操作是把一个空格填上数字。值空间里一行 board[row][col] = n 就完事,类型空间里则需要重新构造整个棋盘对象。
typescript复制type Fill<B extends Board, K extends CellKey, N extends Digit> = {
[P in keyof B]: P extends K ? N : B[P];
};
这种写法用映射类型把 B 的所有键重新枚举一遍:遇到要填充的格子键就换成新数字,其他格子保留原值。它不会修改原类型,而是产生一个新的 Board 类型,这也符合函数式编程中"不可变更新"的思路。
3.4 候选值:用联合类型天然表达"可能集合"
某个空格能填什么数字,取决于该行、列、宫已经用掉了哪些数字。普通代码会用一个数组收集,然后差集。类型层面可以用 Exclude 直接做差集:
typescript复制type UsedNumbers<B extends Board, K extends CellKey> =
UsedInRow<RowOf<K>, B> | UsedInCol<ColOf<K>, B> | UsedInBox<BoxOf<K>, B>;
type Candidates<B extends Board, K extends CellKey> =
B[K] extends Digit
? B[K] // 已经填好的格子,候选就是它本身
: Exclude<Digit, UsedNumbers<B, K>>;
这里能看出联合类型的强大:UsedNumbers 是同一行、列、宫所有已填数字的并集,Exclude<Digit, ...> 得到的还是联合类型。比如候选数字可能是 3 | 5 | 7,接下来只要对这个联合类型做分布式遍历,就能模拟"尝试每个候选数字"。
这一节看起来只是在搭骨架,但所有后续的回溯逻辑都建立在这些基础类型之上。骨架搭歪了,后面每一步都会寸步难行。
4. 核心难点:类型系统里的"尝试-失败-回退"到底怎么写
4.1 天真方案:分布式条件类型的"并行陷阱"
最容易想到的写法是:用 Extended 把一个空格的所有候选数字作为联合类型传进去,然后交给分布式条件类型逐个尝试。
typescript复制type SolveLoop<B extends Board> =
GetFirstEmpty<B> extends infer Pos extends CellKey
? Candidates<B, Pos> extends infer Cands extends Digit
? Cands extends Digit
? SolveLoop<Fill<B, Pos, Cands>> extends infer R
? R extends never ? never : R
: never
: never
: never
: B;
上面这段代码的意图是:Cands extends Digit 会触发联合类型的分发,于是 3 | 5 | 7 中的每个数字都会各自填补同一个空格,分别递归出三个结果。最终 SolveLoop 会得到一个联合类型,里面包含多个解法的棋盘类型。
如果不需要全部解,只要一个解法,这个写法就会出问题:你无法控制"选第一个成功分支",因为所有分支都在同一时间被展开。更麻烦的是,数独本身可能存在多个解,那么这里返回的就是多个棋盘类型的联合,后续再对这些棋盘做判断时,逻辑会完全乱掉。
4.2 用元组保存候选数字列表,实现真正的顺序回溯
为了解决"并行"导致的问题,真实实现里通常不会直接遍历候选数字联合,而是先把候选数字顺序化,存进元组 [1, 2, 3, ...],再用"取出第一个元素 -> 尝试 -> 如果递归失败就取下一个"的方式模拟回溯。
这里的关键工具是标准类型体操里的 Shift:
typescript复制type Shift<T extends any[]> =
T extends [infer _, ...infer Rest] ? Rest : [];
然后把候选数字映射成元组,或者干脆用固定顺序 [1,2,3,4,5,6,7,8,9] 先尝试一遍,再用 IsValid 过滤掉不合法的数字。过滤后仍然得到一个数字联合,再把它转成元组。这一步有很多种写法,比如:
typescript复制type DigitTuple = [1, 2, 3, 4, 5, 6, 7, 8, 9];
完整的有序尝试逻辑可以简化表达成:
typescript复制type TryDigits<B extends Board, Pos extends CellKey, Ds extends Digit[]> =
Ds extends [infer D extends Digit, ...infer Rest extends Digit[]]
? IsValid<B, Pos, D> extends true
? SolveOne<Fill<B, Pos, D>> extends infer R
? R extends never
? TryDigits<B, Pos, Rest> // 当前数字失败,尝试下一个
: R // 找到解,直接返回
: never
: TryDigits<B, Pos, Rest> // 当前数字冲突,跳过
: never; // 所有数字都失败,回溯
TryDigits 的语义非常接近普通递归函数:Ds 是待尝试的数字数组,每次都取出第一个元素。如果当前数字填进去之后,更深层的 SolveOne 能成功,就返回成功结果;如果返回 never,就递归处理剩余的 Rest。当数组被掏空时还找不到解,就返回 never,通知上一层"这个分支走不通,该换数字了"。
SolveOne 则负责找到第一个空格,然后调用 TryDigits:
typescript复制type SolveOne<B extends Board> =
GetFirstEmpty<B> extends infer Pos extends CellKey
? TryDigits<B, Pos, DigitTuple>
: B;
这里的 GetFirstEmpty 需要按固定顺序遍历 81 个格子。普通代码可以用双重 for 循环,类型代码只能用递归逐个检查:
typescript复制type GetFirstEmpty<B extends Board, Keys extends CellKey[] = AllCells> =
Keys extends [infer K extends CellKey, ...infer Rest extends CellKey[]]
? B[K] extends '.'
? K
: GetFirstEmpty<B, Rest>
: never;
AllCells 是一个固定的 CellKey 数组,列举了 '00' 到 '88' 的全部 81 个键。这个数组可以手动写,也可以用映射类型生成,但实际代码里手写一个 AllCells 反而更省心,因为编译器不会因为在生成数组上花掉过多资源。
4.3 never 在这里扮演的角色:整个回溯的"失败信号"
看到这里你会发现,整个类型级数独的精髓就是对 never 的运用。它被当成一个特殊的"失败哨兵":任何递归分支一旦走到死路,就返回 never;上一层检测到 R extends never,就认为当前数字尝试失败,于是换下一个数字。如果整个 TryDigits 都返回 never,说明当前空格无解,于是再往上一层回溯。
这种用法和普通编程里的 null 或 false 类似,但更彻底。因为 never 在联合类型里会自动被吸收,所以如果一个错误的分支混进了结果,它不会污染最终棋盘类型。你在最外面看到的 SolveOne 结果如果是一个具体棋盘类型而不是 never,就代表搜索成功了。
4.4 剪枝优化:不要总是从第一个空格开始找
真正的类型级数独求解器还会做一个优化:优先填候选数字最少的空格。这个启发式叫 MRV,可以大幅减少分支数量。类型层面的候选数字数量怎么统计?可以把候选联合转成元组,然后递归数长度,但代价很高。更简单的办法是把这个逻辑也做在 GetFirstEmpty 里:每次遍历时去掉已经确定的空格,只对空白格计算 Candidates 的联合成员数量,选最少的一个。不过为了控制类型计算复杂度,很多实现干脆固定从第一个空格开始。如果遇到特别难的数独,再从 MRV 优化入手。
5. 调试类型代码的骚操作:断言工具、VSCode 的 Hover 与错误堆栈
类型代码没有 console.log,也不能打断点,第一次写这种代码的人基本会疯。这里分享几个我自己用下来很顺手的调试手段,以及当初用 DeepSeek 排查问题的思路。
5.1 用 VSCode Hover 查看"中间变量"
最简单的方法:把需要检查的中间类型定义成一个 Debug 别名。
typescript复制type Debug<T> = { __debug: T };
type CurrentBoard = SolveOne<SampleBoard>;
type DebugBoard = Debug<CurrentBoard>;
在编辑器中 hover 到 DebugBoard,就能看到编译器求值后的 CurrentBoard。这种"暴露中间层"的做法比盯着错误报表强得多。
5.2 用 Equal 和 Expect 写类型级单测
复杂逻辑不能只靠肉眼观察,我习惯写一堆类型断言:
typescript复制type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type Test_UsedInRow_0 = Expect<
Equal<UsedInRow<'0', SampleBoard>, 5 | 3 | 6 | 7 | 9>
>;
当断言不成立时,编译器直接报错。这样每一次改动都能立刻知道哪个检查崩了。这段 Equal 是从 TypeScript 官方 issue 里流传出来的,别自己写 A extends B ? (B extends A ? true : false) : false,那在联合类型和函数类型前会踩坑。
5.3 故意制造错误,让编译器吐出内部结果
没有 Debug 的时候,有一种"野蛮"但有效的办法:把中间结果塞进一个必然报错的位置。
typescript复制const reveal: SampleBoard = {} as any; // 错
const reveal2: Debug<Candidates<SampleBoard, '00'>> = 1;
第二种写法的 Expect 不会报错,但如果你把类型写到赋值的左侧,编译器会在错误信息里完整显示这个类型。比 Hover 更适合截图发给别人分析。
5.4 拆小步,再合并
写类型级数独最忌讳上来就写一个 50 行的 SolveOne。我当时的做法是:先确认 UsedInRow 能正确提取某一行数字,再确认 Candidates 能算出某个空格的可填数字,用断言一个个测过,最后才组合成 TryDigits。这种"自底向上"的方式能极大减少排查范围。
另外,DeepSeek 在这个过程中的价值很大。它能把嵌套很深的条件类型展开成一步步的伪代码,还能帮我解释为什么某个分支会返回 never。但要注意,AI 生成的类型代码偶尔会有逻辑孔洞,必须配合上面的断言工具验证后才能放进项目里。
6. 从玩具到可复用:功能扩展、性能瓶颈与我的实战心得
6.1 把棋盘类型变成可读的字符串结果
算出一个 Board 类型后,如果你只想看解法,直接 hover 棋盘对象非常痛苦。更友好的做法是写一个 FormatBoard<B>,把 81 个键映射成一行字符串。
typescript复制type RowStr<R extends RowKey, B extends Board> =
`${B[`${R}0`]}${B[`${R}1`]}${B[`${R}2`]}${B[`${R}3`]}${B[`${R}4`]}${B[`${R}5`]}${B[`${R}6`]}${B[`${R}7`]}${B[`${R}8`]}`;
type FormatBoard<B extends Board> =
`${RowStr<'0', B>}\n${RowStr<'1', B>}\n${RowStr<'2', B>}\n${RowStr<'3', B>}\n${RowStr<'4', B>}\n${RowStr<'5', B>}\n${RowStr<'6', B>}\n${RowStr<'7', B>}\n${RowStr<'8', B>}`;
这里使用模板字面量类型按行拼接,最终得到一个可读的多行字符串。配合最开始说的"字符串棋盘 -> Board 类型 -> 求解 -> 格式化输出",你完全可以在类型层做一个编译器自动运行的"命令行工具"。
6.2 判断数独是否有唯一解
如果想检查一道题是否有唯一解,可以把求一个解的 SolveOne 改成求全部解的 SolveAll,让所有成功分支都进入结果联合类型,再看联合类型的成员数量。
typescript复制type SolveAll<B extends Board> =
GetFirstEmpty<B> extends infer Pos extends CellKey
? TryAllDigits<B, Pos, [1,2,3,4,5,6,7,8,9]>
: B;
type CountUnion<U, Count extends 1[] = []> =
[U] extends [never]
? Count['length']
: U extends unknown
? CountUnion<Exclude<U, U>, [...Count, 1]>
: Count['length'];
但注意,这种暴力搜索会让实例化数量急剧膨胀。9x9 的普通数独如果空格较多,求解所有解通常会让 tsc 直接卡死。我自己实际测过,常规家用机器上能跑完的题目,空格数最好控制在 20 个以内,还要配合 MRV 剪枝。
6.3 扩展到其他盘面和变种
这套工具并不绑定 9x9。把 RowKey 换成 '0' | '1' | '2' | '3',CellKey 就会自动生成 16 键的 4x4 棋盘;调整宫格映射表,就能支持 6x6、12x12,甚至不规则宫格。我建议所有想入门类型级数独的人,先从 4x4 开始,跑通回溯逻辑后再扩大到 9x9。很多看似复杂的类型代码,在小棋盘上会简单到让你突然顿悟。
6.4 性能瓶颈:编译器的极限
最后必须泼一盆冷水:类型级数独代码放到真实项目中,基本属于"自杀式编程"。它带来的问题主要有三个:
| 问题 | 表现 | 应对思路 |
|---|---|---|
| 实例化深度超限 | 报 Type instantiation is excessively deep |
尽量写尾递归形式,利用 TS 4.5+ 的尾递归优化 |
| 实例化数量爆炸 | 编译时间暴涨,IDE 卡死 | 用 MRV 剪枝,减少无效分支 |
| Hover 信息过长 | 编辑器的类型提示几乎不可读 | 拆小类型,给关键步骤起有语义的名字 |
tsc 对复杂类型的单次实例化深度有硬性限制,超过一定层数直接报错。尾递归条件类型可以在很多场景下被编译器优化,但并不是所有递归都满足优化条件。数独回溯本身不是尾递归,所以当深度过深时,只能通过减少搜索空间来规避。
6.5 我的实战心得
把数独塞进类型系统这件事,最大的收获从来不是"我能用类型解数独了",而是它逼我真正搞懂了条件类型的分布式行为、infer 在模板字面量中的推断规则,以及 never 在联合类型中的特殊地位。以前看别人写复杂类型库,很多地方是半猜半用;写过这个玩具之后,再遇到类型报错,第一反应不再是 as any,而是先想"这里有没有可能在条件类型分发时多了几个分支"。
如果看完这篇你也手痒,我的建议是:别从 9x9 开始。先用 4x4 写一个能判断合法性、能填唯一候选数字的类型集合,然后慢慢把它扩展成回溯求解器。当你第一次在编辑器里 hover 出一个完整的解法棋盘类型时,那种满足感,值得每一个 TypeScript 玩家体验一次。
