InterviewsVector
advancedRare5 min read · Updated Jul 18, 2026

Implement Currying with Placeholders

Implement curry with placeholder support in JavaScript — Symbol sentinels, fill-then-append merging, and the completion rule that most implementations get wrong.


The problem

Extend curry with a placeholder _ so arguments can arrive out of order: curriedSubtract(10, _, 5)(3) fills the hole → subtract(10, 3, 5).

This is the standard hard follow-up to currying, and it's genuinely trickier than it looks — most blog implementations (and lodash-cargo-cult versions with a string '_') contain one or both of two real bugs. Solving it cleanly requires stating two rules precisely:

  1. Merging rule: incoming arguments fill existing placeholders left-to-right first; leftovers append to the end.
  2. Completion rule: the function runs only when the first fn.length positions are filled with no placeholder among them. Counting non-placeholder args isn't enough — where they sit matters.

Implementation

const _ = Symbol("placeholder"); // a Symbol can't collide with real data
 
function curry(fn) {
  return function curried(...args) {
    const needed = args.slice(0, fn.length);
    const complete = needed.length >= fn.length && !needed.includes(_);
 
    if (complete) {
      return fn.apply(this, needed);
    }
 
    return function (...nextArgs) {
      // Rule 1: fill placeholders left-to-right, then append leftovers
      const merged = [];
      let n = 0;
      for (const arg of args) {
        merged.push(arg === _ && n < nextArgs.length ? nextArgs[n++] : arg);
      }
      merged.push(...nextArgs.slice(n));
      return curried.apply(this, merged);
    };
  };
}

Dry run

const subtract = (a, b, c) => a - b - c;
const cs = curry(subtract);
 
cs(10, _, 5)(3);
// call 1: args=[10, _, 5] → needed has a placeholder → not complete
// call 2: nextArgs=[3] → merge: 10 stays, _ ← 3, 5 stays → [10, 3, 5]
//         needed=[10,3,5], no placeholder, length 3 → subtract(10,3,5) = 2 ✓
 
cs(_, 3)(10, 5);   // merge: _ ← 10, 3 stays, append 5 → [10,3,5] → 2
cs(10)(_, 5)(3);   // [10] → [10,_,5] → fill → [10,3,5] → 2
cs(_, _, 5)(_, 3)(10); // placeholders can even be re-supplied as placeholders:
// [_,_,5] + [_,3] → [_,3,5] + [10] → [10,3,5] → 2

That last case — a placeholder filled by another placeholder — falls out of the merge rule for free, and it's the input interviewers use to break over-clever implementations.

The two planted bugs

Bug 1: filtering placeholders before calling. The common broken version does validArgs = args.filter(a => a !== _) and, on completion, calls fn(...validArgs) — which collapses positions. For cs(10, _, 5, 3), filtering gives subtract(10, 5, 3); the placeholder said the 3 belongs in the middle: subtract(10, 3, 5). Cruelly, with 10 - x - y both orders give 2, so the broken version passes its own example — the perfect demonstration of why test cases must target the semantics (use (a - b) * c-style non-commutative functions), not just happy outputs.

Bug 2: counting instead of positioning in the completion check. cs(_, 2, 3, 4) for arity 3 has three non-placeholder args, so a count-based check calls subtract(2, 3, 4) — silently ignoring that position a was explicitly deferred. The positional rule (no placeholder within the first fn.length slots) refuses to run until the hole is filled: a later (1) merges to [1, 2, 3, 4] and calls subtract(1, 2, 3).

And the sentinel choice is a third, quieter trap: a string '_' placeholder means the data value "_" can never be passed as a real argument. A Symbol is unforgeable — nothing in user data can equal it. (Lodash uses the lodash function object itself as the sentinel — same unforgeability idea.) Also note merged.includes(_)-style checks: includes uses SameValueZero, so a stray NaN argument doesn't confuse anything — a nice tie-in to Object.is semantics.

Edge cases interviewers probe

  • Trailing extra args beyond arity — sliced off by slice(0, fn.length) at invoke time (they existed only to fill holes); state that policy.
  • All placeholderscs(_, _, _) never completes until real values arrive; each call just re-merges.
  • fn.length fragility — defaults and rest params undercount arity, inherited from plain curry; the explicit-arity parameter fix applies here identically.
  • this forwarding — same as base curry: function collectors + apply(this, ...).
  • No mutationmerged is built fresh per call; sibling partials sharing a prefix must not see each other's fills.

Common mistakes

  • The filter-then-call position collapse (bug 1).
  • Count-based completion (bug 2).
  • A forgeable sentinel ('_', null, undefined — the last also breaks passing legitimate undefined).
  • Mutating nextArgs with shift() mid-map — works until someone edits it; the index-cursor loop states the algorithm honestly.
  • Testing only with commutative-ish examples that hide positional bugs.

Follow-up questions

  • "Why a Symbol?" — unforgeability; the sentinel must live outside the space of possible user data. Same reasoning as the temp-key in custom call.
  • "Implement partial with placeholders instead." — one merge pass, no recursion or arity check — the simpler cousin; articulating what disappears shows you see the structure.
  • "What's the complexity?" — each call is O(k) in accumulated args; a chain of n single-arg calls is O(n²) total — trivia-sized, but it demonstrates you can analyze closures like any other data structure.
  • "Does any real library ship this?" — Ramda (R.__) and lodash (_.curry with the lodash placeholder); knowing placeholders are a shipped, used feature — mainly for slotting data into the last position of config-first APIs — grounds the exercise.
  • "Property-test it?" — generate random arities, random placeholder patterns, and assert against the oracle of direct application — this question is unusually well-suited to property testing precisely because bug 1 sneaks past example-based tests.

  • pipe()

    intermediate

    Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.

  • Currying

    intermediate

    Transform f(a, b, c) into f(a)(b)(c): the classic closure exercise, with arity handling.

  • _.partial()

    intermediate

    Pre-fill leading arguments of a function — partial application, and how it differs from currying.

  • _.once()

    beginner

    Guarantee a function runs exactly once and returns its cached result forever after.