☰
用 TypeScript 类型系统实现动态参数柯里化:type-challenges 00462 Currying 2 深度解析
2026/10/1 13:22:39 网站建设 项目流程
  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载

type-challenges 的第 00462 题(Currying 2,难度Extreme / 地狱级)要求为「动态参数柯里化」函数DynamicParamsCurrying写出正确类型:原函数可以一次接收全部参数,也可以分批(每批至少一个参数)传入,只要所有参数的类型与顺序一致,最终必须精确返回原函数的返回值类型。本文以该题目的 README.md(及 中文版)为核心,结合 template.ts 与 test-cases.ts 逐条拆解需求与测试用例,并给出能够完整通过全部断言的类型级参考实现。读完本文,你将掌握「变长元组前缀匹配 + 递归柯里化」这一在类型挑战中极具复用价值的核心套路。

挑战总览

项目内容
挑战编号00462(Currying 2)
难度extreme(info.yml 中标记为difficulty: extreme)
作者Kim(hubvue)
模板文件template.ts:declare function DynamicParamsCurrying(fn: any): any
测试文件test-cases.ts
相关挑战00017 · Currying 1(hard)

题目编号 00462 是 00017「Currying 1」的进阶版本(info.yml 中related: 17)。Currying 1 要求每次调用只接收一个参数;而 Currying 2 允许每次调用一次性接收一至多个参数,两者在类型实现上的复杂度差别很大。

柯里化与「动态参数柯里化」

题目对柯里化的定义是:

Currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each take a single argument.

即将「一次接收多个参数的函数」转换为「一系列各自接收单个参数的函数」。但题目进一步指出:在实际前端开发中,参数个数动态化的柯里化反而更常见,例如Function.bind(this, [...params]):

const func = (a: number, b: number, c: number) => { return a + b + c } const bindFunc = func(null, 1, 2) const result = bindFunc(3) // result: 6

bind允许一次性绑定任意数量的参数,剩余参数再在调用时补齐——这正是「动态参数柯里化」的运行期形态。因此题目在 Currying 1 的基础上,要求为这种动态版本写出类型:

const add = (a: number, b: number, c: number) => a + b + c const three = add(1, 1, 1) const curriedAdd = DynamicParamsCurrying(add) const six = curriedAdd(1, 2, 3) // 一次给满 3 个参数 const seven = curriedAdd(1, 2)(4) // 先给 2 个,再给 1 个 const nine = curriedAdd(2)(3)(4) // 每次给 1 个,共 3 次

six、seven、nine的类型都应被推断为number(即原函数add的返回值类型)。顺带一提:英文原文档中第三行示例变量名为nine(2 + 3 + 4 = 9),而中文版 README 中该处写作eight,从计算结果看应属笔误,以英文版nine为准。

需求拆解:三条硬性规则

题目原文对DynamicParamsCurrying的约束可以拆解为以下三条:

  1. 输入函数参数个数可为零到多个:DynamicParamsCurrying接受的函数可以有任意数量的参数(包括零参数)。
  2. 柯里化后的函数每次调用至少接收一个参数:与 Currying 1「每次恰好一个参数」不同,这里每次调用允许传入一至多个参数,但不能为空调用。
  3. 分批方式自由,但总量与顺序必须匹配:所有调用传入的参数,按顺序拼接后必须与原函数的参数列表完全一致;一旦全部参数给齐,就应返回原函数精确的返回值类型。

这三条规则在测试文件中体现得非常明确。

测试用例逐条解读

测试文件 test-cases.ts 构造了两个待柯里化函数:一个 3 参数函数和一个 7 参数函数:

const curried1 = DynamicParamsCurrying((a: string, b: number, c: boolean) => true) const curried2 = DynamicParamsCurrying((a: string, b: number, c: boolean, d: boolean, e: boolean, f: string, g: boolean) => true)

3 参数函数的三种调用方式

const curried1Return1 = curried1('123')(123)(true) // 每次 1 个参数 const curried1Return2 = curried1('123', 123)(false) // 先 2 个,再 1 个 const curried1Return3 = curried1('123', 123, true) // 一次 3 个

三种写法都合法,且curried1Return1/2/3的类型都必须精确等于boolean。

7 参数函数的十种分批模式

const curried2Return1 = curried2('123')(123)(true)(false)(true)('123')(false) const curried2Return2 = curried2('123', 123)(true, false)(true, '123')(false) const curried2Return3 = curried2('123', 123)(true)(false)(true, '123', false) const curried2Return4 = curried2('123', 123, true)(false, true, '123')(false) const curried2Return5 = curried2('123', 123, true)(false)(true)('123')(false) const curried2Return6 = curried2('123', 123, true, false)(true, '123', false) const curried2Return7 = curried2('123', 123, true, false, true)('123', false) const curried2Return8 = curried2('123', 123, true, false, true)('123')(false) const curried2Return9 = curried2('123', 123, true, false, true, '123')(false) const curried2Return10 = curried2('123', 123, true, false, true, '123', false)

这十种写法覆盖了「每次 1 个」「每次多个」「混合分批」等几乎所有合法的分拆组合,且curried2Return1 ~ curried2Return10的类型都必须精确等于boolean。这意味着你的类型实现必须支持任意前缀长度的分批调用,而不是只能每次递归一个参数。

两个必须报错的用例

// @ts-expect-error const curried1ReturnWrong = curried1('123')(123)('wrong arg type') // @ts-expect-error const curried1ReturnWrong2 = curried1('123')()(123)(true)
  • curried1ReturnWrong:第三个参数传成了string,而原函数第三个参数是boolean,必须类型报错;
  • curried1ReturnWrong2:中间出现了一次空调用()(零参数),违反「每次调用至少一个参数」的规则,必须类型报错。

@ts-expect-error要求这两行的确产生类型错误,否则测试本身会失败。

断言机制

所有返回值的类型通过Equal严格断言:

type cases = [ Expect<Equal<typeof curried1Return1, boolean>>, // ... 其余 12 个断言 ]

Equal与Expect定义在仓库的 utils/index.d.ts 中:Equal<X, Y>使用「同一泛型函数签名的条件类型比较」实现结构等价判断,Expect<T extends true> = T则要求断言结果严格为true。测试文件从@type-challenges/utils导入它们,而根目录 package.json 中的"@type-challenges/utils": "workspace:*"说明该包以 pnpm workspace 形式链接到仓库的 utils 工作区。

从 Currying 1 到 Currying 2:解法推导

回顾 Currying 1:每次恰好一个参数

00017 的测试用例 展示了 Currying 1 的期望结果:

const curried1 = Currying((a: string, b: number, c: boolean) => true) // 期望类型: // (a: string) => (b: number) => (c: boolean) => true const curried3 = Currying(() => true) // 期望类型:() => true

Currying 1 的典型解法是「单参数递归」——每次从元组头部取出一个参数类型,生成一层函数:

declare function Currying<T extends unknown[], R>( fn: (...args: T) => R, ): T extends [] ? () => R : Curried1<T, R> type Curried1<T extends unknown[], R> = T extends [infer F, ...infer Rest] ? (arg: F) => Curried1<Rest, R> : R

这种写法只能让柯里化后的函数每次恰好接收一个参数,无法满足 Currying 2 中curried1('123', 123)(false)这种「一批传多个参数」的调用方式。

Currying 2 的难点

Currying 2 需要让每次调用自由选择参数个数(至少一个)。这就要求类型实现能够回答两个问题:

  1. 本次调用传入的参数P,是不是原参数列表T的一个合法前缀?(类型与顺序都要匹配)
  2. 如果是,把前缀消耗掉,剩余部分Rest继续递归柯里化;如果不是,返回never使调用报错。

「元组前缀匹配」正是通过变长元组推断实现的:T extends [...P, ...infer Rest]。当P是T的前缀时,TypeScript 可以把Rest推断为剩余参数类型;同时还要处理「P恰好等于T」的终止情况,以及「P为空」的非法情况。

参考实现

下面给出一种能够完整通过 test-cases.ts 全部断言的参考实现(仓库 template.ts 仅给出declare function DynamicParamsCurrying(fn: any): any的起点,解答正是从这条声明出发):

declare function DynamicParamsCurrying<T extends unknown[], R>( fn: (...args: T) => R, ): T extends [] ? () => R : Curried<T, R> type Curried<T extends unknown[], R> = <P extends unknown[]>( ...args: P ) => P extends [] ? never // 规则 2:每次调用至少一个参数 : P extends T // 规则 3:参数已全部给齐 ? R // → 返回原函数返回值 : T extends [...P, ...infer Rest] // P 是 T 的合法前缀 ? Curried<Rest, R> // → 消耗 P,继续柯里化剩余参数 : never // 数量/类型不匹配 → 调用报错

逐层拆解:

  • 外层:DynamicParamsCurrying<T, R>先从原函数推断出参数元组T与返回值R。若T为空元组(零参数函数),直接返回() => R——这对应题目「函数参数个数可为零」的规则,也与 Currying 1 中Currying(() => true)得到() => true的行为一致。
  • <P extends unknown[]>(...args: P):柯里化后的每一层都是一个泛型函数,调用方本次传入的参数被推断为元组P,从而支持一次传入任意多个参数。
  • P extends [] ? never:显式拦截空调用。没有这个分支,空调用会落入前缀匹配(空元组天然是任意元组的前缀),导致无休止递归或错误类型。
  • P extends T ? R:本次调用恰好覆盖全部剩余参数,直接返回原函数返回值类型R。
  • T extends [...P, ...infer Rest]:P是T的合法前缀(长度不超过、逐个类型匹配),把剩余部分Rest交给下一层递归。
  • never:其余一切情况(类型不匹配、参数过多等)都归于never,使该调用在类型层面报错。

用手动推演验证

以curried1('123', 123)(false)为例(此时T = [string, number, boolean],R = true):

  1. 第一层调用curried1('123', 123):P = [string, number]。P extends []为假;[string, number] extends [string, number, boolean]为假;走前缀匹配[string, number, boolean] extends [string, number, ...infer Rest],得到Rest = [boolean],返回Curried<[boolean], true>。
  2. 第二层调用(false):P = [boolean]。P extends []为假;[boolean] extends [boolean]为真 → 返回true。

再看必须报错的curried1('123')()(123)(true):

  1. 第一层('123'):P = [string],前缀匹配得Rest = [number, boolean],返回Curried<[number, boolean], true>。
  2. 第二层():P = [],命中P extends [] ? never→ 类型为never,调用报错,满足@ts-expect-error。

7 参数函数的任意分批模式同理:无论每次传 1 个还是多个,只要逐层做前缀匹配并递归消耗,最终都能在最后一层通过P extends T回到boolean。

边界情况与易错点

  1. 空调用必须报错:这是与 Currying 1 最容易被忽略的差异。若不显式处理P extends [],空调用会被当作「空前缀」继续递归,产生错误类型甚至触发「类型实例化过深」的编译错误,而不是干脆利落的类型错误。
  2. 参数类型必须逐位匹配:前缀匹配T extends [...P, ...infer Rest]会同时校验元素类型。curried1('123')(123)('wrong arg type')正是因为string不匹配boolean而落入never。
  3. 一次性给满参数:curried1('123', 123, true)中P恰好等于T,命中P extends T ? R分支,直接得到boolean,不需要再产生一层函数。
  4. 参数过多:若某次调用传入的参数数量超过剩余数量,T extends [...P, ...infer Rest]无法匹配,结果为never,同样报错。
  5. unknown[]而非any[]:使用unknown[]作为泛型约束可以保留参数的类型信息,避免any污染推断结果;这是类型挑战中「宁可严格、不可放水」的通用原则。
  6. 递归深度:测试中最长的参数列表是 7 个,逐层递归的深度为 7,远低于 TypeScript 默认的递归上限,无需担心「类型实例化过深」。

在本仓库中动手验证

本仓库是可读的挑战合集,你可以这样复现与验证:

  1. 在 template.ts 中把declare function DynamicParamsCurrying(fn: any): any替换为上述参考实现(注意template.ts无 import/export,属于全局脚本文件,其中的declare声明会被同编译单元内的测试文件感知)。
  2. 用 TypeScript 编译器对 test-cases.ts 做类型检查(仓库 devDependencies 中的 typescript 版本为^5.3.3,见根目录 package.json;变长元组推断所需的最低版本为 TS 4.0)。例如npx tsc --noEmit questions/00462-extreme-currying-2/template.ts questions/00462-extreme-currying-2/test-cases.ts,具体编译选项以仓库的 tsconfig.json 与 tsconfig.base.json 为准。
  3. 若类型检查零错误,说明 13 条Expect<Equal<...>>断言全部通过、两处@ts-expect-error也确实报错,解答即成立。可参考的对照材料还包括 Currying 1 的测试用例(体会单参数递归与多参数前缀递归的差异)以及 utils/index.d.ts 中Equal的实现。

小结

Currying 2 是 type-challenges 中「Extreme / 地狱级」难度的代表性题目:它要求把「任意分批、每次至少一个参数、给满即返回原返回值」的运行时语义完整编码进类型系统。核心套路「T extends [...P, ...infer Rest]前缀匹配 + 递归柯里化 + 空调用拦截」不仅适用于本题,也广泛复用于其余涉及「元组分段消费」的挑战(例如 Split、Chunk、DropString 等)。掌握它,你就掌握了类型级「流式参数分发」的通用解法。

  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询