A shared utility module curries helpers with a `curry(fn)` that completes once collected arguments reach `fn.length`. After a colleague adds a default value to one helper's last parameter, curried call sites start producing wrong numbers and `NaN`. What happened, and how would you fix it?
answer
- a signature edit changed behaviour elsewhere
- declared arity is not parameter count
- counting stops at the first default
- helper completed one step too early
- state the arity explicitly instead
basics
~20 sAdding a default value shrinks fn.length, because it counts only parameters declared before the first default or rest parameter. The curry helper therefore believes it has enough arguments early, calls through too soon, and the still-outstanding argument corrupts the result downstream.
solid answer
~50 s`fn.length` is not "how many arguments this function uses" — it is the count of parameters declared **before** the first one with a default value or a rest parameter. Adding `= 1` to the last parameter drops the reported arity from three to two, so the helper's `args.length >= fn.length` test passes one step early and invokes the target with the default filling in. Call sites that still pass a third argument are then applying it to a plain result rather than to a collector, which turns into a thrown `TypeError` or arithmetic on a non-number. Nothing warns you, because JavaScript never validates argument counts. The fix is to stop inferring arity from the declaration: accept it explicitly (`curry(fn, 3)`), or keep curried targets on plain fixed parameters and default inside the body. Then add a test asserting the intermediate step is still a function.
code
javascript · 7 linesconst arity = (fn) => fn.length;
console.log(arity((a, b, c) => 0)); // 3
console.log(arity((a, b, c = 1) => 0)); // 2 default truncates the count
console.log(arity((a, b = 1, c) => 0)); // 1 truncation starts at the first default
console.log(arity((...xs) => 0)); // 0 rest parameters count for nothing
console.log(arity(({ a, b }) => 0)); // 1 one destructured parametergo deeper
Remember that fn.length stops counting at the first parameter with a default value or a rest parameter, so it is often smaller than the number of parameters written out.
Explain how an arity test in a curry helper consumes that number, and walk through the exact call that completes one step early once a default is added.
Diagnose it from the symptom: trace a wrong value back to an implicit fn.length dependency, remove the guess by passing arity explicitly, and add a test on the intermediate shape.
Set the rule that behaviour must never be driven by declared arity in shared utilities, since it turns an innocuous signature edit in one file into silent corruption in another.
## What `fn.length` actually reports `Function.prototype.length` is the number of parameters **before the first parameter that has a default value, and before any rest parameter**. It is a static property of the declaration, not a description of how the body behaves: ```js ((a, b, c) => 0).length; // 3 ((a, b, c = 1) => 0).length; // 2 ((a, b = 1, c) => 0).length; // 1 — counting stops at the first default ((...xs) => 0).length; // 0 (({ a, b }) => 0).length; // 1 — one destructured parameter ``` Note the third line: a default in the *middle* truncates the count even though a later parameter has no default. And a destructured object parameter counts as one, no matter how many properties it pulls out. ## The failure, step by step Before the change: ```js const rate = curry((base, bonus, tax) => (base + bonus) * tax); rate(100)(20)(1.2); // 144 — fn.length is 3, so three arguments are collected ``` After `tax = 1` is added, `fn.length` is `2`. Now `rate(100)(20)` already satisfies `args.length >= fn.length`, so the helper calls the target immediately, the default supplies `tax`, and the expression evaluates to `120` — a plausible-looking wrong number. The call sites that still write the third step are now applying `1.2` to the number `120`, which throws `TypeError: rate(...)(...) is not a function`; where the intermediate value instead flows into arithmetic or string building, you get `NaN` or nonsense output rather than an exception. Either way the symptom is far from the cause: the edit that broke it lives in a different file and looked entirely harmless. ## Why this class of bug is nasty - **It is silent at the boundary.** JavaScript never checks arity at a call site; missing arguments are `undefined` and surplus ones are ignored. Nothing between the declaration change and the wrong output raises a warning. - **It is action-at-a-distance.** The helper's contract is written in a *different* file from the change that violated it, and `fn.length` is an implicit dependency nobody thinks about while adding a default. - **The value's very shape is dynamic.** The same expression can legitimately be a function or a number depending on how many arguments arrived, so no amount of reading the call site tells you which you have. ## Fixes, in order of preference **1. Make the arity explicit.** The helper should not guess: ```js function curry(fn, arity = fn.length) { return function curried(...args) { if (args.length >= arity) return fn(...args); return (...rest) => curried(...args, ...rest); }; } const rate = curry((base, bonus, tax = 1) => (base + bonus) * tax, 3); ``` The intended arity is now stated at the point of currying, where the intent lives, and a later default cannot move it. **2. Keep curried targets on plain parameters.** Move defaulting into the body: ```js const rate = curry((base, bonus, tax) => (base + bonus) * (tax ?? 1)); ``` This keeps `fn.length` honest at the cost of losing the parameter-default syntax. It is the cheapest rule to enforce by convention: *functions passed to `curry` declare fixed parameters only.* **3. Never curry a variadic function.** With a rest parameter, `fn.length` is `0`, so the helper calls through on the very first invocation — currying is meaningless for a function whose argument count is not fixed. **4. Test the intermediate shapes.** A unit test asserting `typeof rate(100)(20) === 'function'` fails loudly the moment the arity drifts, converting a silent data bug into a red build. ## The wider lesson Any mechanism that reads `fn.length` inherits this fragility — curry helpers, argument-count dispatch, and hand-rolled overload resolution alike. Treat declared arity as *documentation the engine happens to expose*, not as a contract you can build behaviour on, and pass the number explicitly whenever behaviour depends on it.
- Why does a default value in the *middle* of the parameter list also hide the parameters after it?Because `length` is defined as the number of parameters before the first one with an initializer or a rest element — the count stops there rather than skipping only the defaulted parameter. So `(a, b = 1, c) => {}` reports `1`, not `2`. Anything reading arity from a signature with a mid-list default is working from a number that describes almost nothing.
- How would you catch this class of regression automatically rather than in production data?Assert intermediate shapes in unit tests: checking that `rate(100)(20)` is still a function breaks the moment the declared arity shifts. Pair that with a convention — or a lint rule — that every call to `curry` passes an explicit arity, so the guess is never made in the first place.
- Is currying ever appropriate for a variadic function?No. Currying presupposes a known, fixed number of arguments to collect; a rest parameter says the count is open-ended, and `fn.length` reports `0`, so any arity-driven helper calls through on the first invocation. If a function genuinely takes a variable number of arguments, specialise it with a closure or `bind` instead of trying to curry it.
saying these in an interview costs you the question
- Believes fn.length counts every declared parameter including defaulted ones
- Blames the curry helper's recursion rather than the arity source
- Suggests fixing it by making callers pass undefined explicitly
- Assumes a missing argument would throw rather than being undefined
- Thinks currying a rest-parameter function works because rest collects everything