ESC

Type to search the knowledge base.

Infinite Currying in JavaScript

Return a function until a sentinel (empty call or valueOf) asks for the sum. Closures, toString tricks, and why this is a closure test, not a math test.

intermediate5 min read
  • javascript
  • closures
  • currying
  • interview

The prompt is sum(1)(2)(3)() and they want 6. Someone reaches for a fixed-arity curry(fn) keyed off fn.length, writes sum as (a, b, c) => a + b + c, and then cannot explain a fourth (4). Wrong problem.

Infinite currying in this interview is not “turn f(a, b, c) into f(a)(b)(c) until arity is met.” That is the currying and partial application page. Here the arity is unknown. You keep returning a function that closes over a running total until a sentinel asks for the number: an empty call, or a coercion via valueOf / toString / Symbol.toPrimitive.

If you treat it as algebra, you will overfit to addition. If you treat it as a closure that accumulates arguments, the same skeleton does product, concat, or a logger.

  • Return a function from every value-taking call. The chain can be arbitrarily long.
  • A terminator (empty invocation, or a primitive coercion) is what yields the total.
  • The running total lives in a closure. That is the test.
  • fn.length arity curry is a different exercise. Link it, don’t reuse it.
  • valueOf / toString make sum(1)(2) + 3 work. They also surprise console.log.

Keep returning a function until someone asks for the number

Two common sentinels, sometimes both on the same object:

Sentinel Call site Mechanism
Empty call sum(1)(2)(3)() arguments.length === 0 (or b === undefined if you accept that footgun)
Coercion sum(1)(2) + 0, `${sum(1)(2)}` valueOf, toString, Symbol.toPrimitive

Empty-call terminator:

function sum(a) {
  return function next(b) {
    if (arguments.length === 0) return a;
    return sum(a + b);
  };
}

sum(1)(2)(3)(); // 6
sum(1)(2)(3)(4)(); // 10

Each return value is a new function whose closure holds the updated total. sum(a + b) is a cheap factory; you are not mutating a single running function, you are handing back the next stage.

arguments.length === 0 is the right empty check. if (b === undefined) treats sum(1)(undefined)() as “stop now” and drops the explicit undefined argument. If the interviewer cares, say you terminate only on a true empty call.

Coercion terminator

Some prompts omit the final (). They want sum(1)(2)(3) + 4 === 10. Ordinary functions coerce to the source text, which is useless here. You attach valueOf (and usually toString) on the returned function.

function sum(a = 0) {
  function next(b) {
    if (arguments.length === 0) return a;
    return sum(a + b);
  }
  next.valueOf = function valueOf() {
    return a;
  };
  next.toString = function toString() {
    return String(a);
  };
  next[Symbol.toPrimitive] = function toPrimitive(hint) {
    return hint === 'string' ? String(a) : a;
  };
  return next;
}

sum(1)(2)(3) + 4; // 10
Number(sum(1)(2)); // 3
String(sum(1)(2)); // "3"
sum(1)(2)(); // 3

+ asks for a primitive with hint "default", which for objects other than Date ends up in valueOf first. Template strings hint "string" and prefer toString. Implementing Symbol.toPrimitive makes the hint explicit so you are not debugging ToPrimitive under a whiteboard clock. The type coercion guide is the long form.

console.log(sum(1)(2)) will print a function, or a number, depending on whether the host calls toString. Don’t promise a specific DevTools rendering.

This is a closure test

The interesting state is a, not the +. A product chain is the same object with a * b. A string builder uses a + String(b). The shape is:

  1. Close over an accumulator.
  2. On a value, return a new function with the updated accumulator.
  3. On a sentinel, return the accumulator.
function accumulate(combine, initial) {
  function stage(acc) {
    function next(value) {
      if (arguments.length === 0) return acc;
      return stage(combine(acc, value));
    }
    next.valueOf = () => acc;
    return next;
  }
  return stage(initial);
}

const prod = accumulate((a, b) => a * b, 1);
prod(2)(3)(4)(); // 24

If you cannot explain why next still sees acc after stage returned, you are not done. Read closures: it is a live binding, not a snapshot, which only matters if you mutate acc instead of passing a new one. Prefer the new-stage style above so mutation never enters the talk.

Not the arity curry

Currying and partial application stops when args.length >= fn.length. sum in this problem has no useful .length if you define it as a rest function, and the whole point is that the caller may pass five numbers or two. Don’t check fn.length. Don’t use bind. Don’t build curry(add) unless they asked for that page’s problem.

Partial application of a known binary add(a, b) is also a different sentence: add.bind(null, 1) returns a one-arg function, not an infinite chain.

Footguns of infinite currying

  1. sum() with no seed. Decide: 0, or throw. The coercion form above uses a = 0 so sum()() is 0.
  2. Non-numbers. '1' + 2 concatenates. Coerce with Number if the prompt is numeric, and say what NaN does.
  3. Forgetting to return next. Returning a too early kills the chain.
  4. Single shared next plus mutable acc. const x = sum(1); const y = x(2); x(3)(); should not pollute y if each call returns a new function. Mutation of one accumulator makes those aliases fight.
  5. toString for display vs value. A function’s default toString is source. Overriding it makes the object lie in string contexts. Fine for the trick; mention the lie.

Interview out loud

“I return a new function every time I see a number, closing over the running total. An empty call is the sentinel that returns the total; if they want sum(1)(2) + 3 I also implement valueOf and Symbol.toPrimitive. I terminate on arguments.length === 0, not on undefined, so an explicit undefined still counts as a value. This is not arity-based curry: I do not look at fn.length, and I would send that variant to the currying page. The same closure shape works for product or concat. I return a new stage each time so two chains from the same prefix do not share mutable state.”

Further reading

Related guides