skip to content

In TypeScript, why is a function that takes fewer parameters assignable where one taking more is expected, and what bug can that allow through?

level: seniorimportance: must knowfreq 52%

answer

  1. fewer is safe, more is not
  2. ignoring an argument cannot break anything
  3. the extras are still passed
  4. point-free hand-off is the hazard
  5. a default only fires for undefined

basics

~20 s

A function that ignores trailing parameters can safely stand in for one that receives them, so TypeScript allows fewer parameters but never more. The risk is that a callback silently receives extra arguments it does not expect, which then land in a parameter that has a default.

solid answer

~50 s

The rule is deliberate and sound: extra arguments a function does not declare are simply ignored, so `(a: number) => void` is assignable to `(a: number, i: number) => void`, while the reverse is rejected because the target's callers would leave the second parameter undefined. It exists because callback-heavy JavaScript would otherwise be unusable — you could not write `items.forEach(x => ...)` without also declaring the index and the array. The trap is that the extra arguments are still *passed*. If the function you hand over has a second parameter with a default, that default never fires: `[10, 20].forEach(save)` where `save(n: number, retries = 3)` compiles cleanly and calls `save(10, 0)` and `save(20, 1)`, feeding the array index into `retries`. The fix is to stop passing the function by reference and wrap it: `items.forEach(n => save(n))`, which pins the arity at the call site.

go deeper

for a junior

Recall the direction of the rule: a function may declare fewer parameters than the signature expects, never more — that is why arr.forEach(x => ...) compiles without mentioning the index.

for a middle

Explain the soundness argument — ignoring an argument cannot break anything, while relying on one that is never supplied can — and show the assignment that fails in the other direction.

for a senior

Demonstrate the production bug: a function passed by name into a higher-order API silently receives its extra arguments, so a defaulted trailing parameter is overwritten. Diagnose it from symptoms and fix it by pinning arity at the call site.

for a principal

Set the convention: which shared functions may be passed point-free, why adding a parameter to a widely-used function is a behavioural change that no type error will catch, and how signature shape (options object versus positional tail) removes the hazard by construction.

## The rule When TypeScript decides whether one function type is assignable to another, it allows the source to declare **fewer** parameters than the target, and never more. ```ts type Handler = (value: number, index: number) => void; const a: Handler = (value: number) => {}; // ok const b: Handler = () => {}; // ok const c: Handler = (value: number, index: number, x: string) => {}; // error ``` ## Why this is sound, not a loophole Assignability asks a single question: can every call the *target* type permits be served by the *source* function? A `Handler` is called with two arguments. A function declaring only the first receives both, ignores the second, and behaves correctly — nothing it does depends on a value it never named. So the substitution is safe. The other direction is not. If a three-parameter function stood in for `Handler`, callers would supply two arguments and the third parameter would be `undefined` while its declared type says otherwise — an unsound situation the compiler refuses. This rule is not a concession to convenience; it is what makes idiomatic JavaScript typable at all. Array callbacks receive three arguments, promise callbacks receive one, event handlers receive an event — and nobody writes out the parameters they do not use: ```ts [1, 2, 3].forEach((n) => console.log(n)); // forEach's callback type has three parameters ``` Without the fewer-parameters rule, that line would not compile. (Note that this is purely about *arity*. How the compiler relates the parameter *types* at each position is a separate question with its own rules.) ## The bug it lets through The arguments the target passes do not disappear — they are still handed over at runtime. Trouble starts when the function you pass by reference has more parameters than you were thinking about, and those parameters accept the type the caller happens to be supplying. ```ts function save(id: number, retries = 3) { // retries is used to bound a retry loop } [101, 102, 103].forEach(save); ``` This compiles. `forEach` calls the callback as `save(101, 0)`, `save(102, 1)`, `save(103, 2)` — the array index lands in `retries`, the default never fires, and the first record is saved with zero retries. Nothing in the type layer objects, because `save` is a two-parameter function being used where a three-parameter callback is expected, and `number` is exactly what the index slot supplies. The general shape: **passing a function by reference into a higher-order API silently binds every argument that API supplies**. A default value on a trailing parameter does not protect you, because a default only fires for `undefined` — and the index is a real number. ## Diagnosing it The symptom is a parameter that inexplicably holds small integers, or a behaviour flag that is on for later items and off for the first. To confirm, look at every point where you pass a function *by name* rather than calling it, and compare its full arity against the callback signature of the API it is handed to. Editors will not flag it — the code is type-correct. ## Fixes **Wrap the call.** This is the reliable fix, because an arrow function pins exactly which arguments are forwarded: ```ts [101, 102, 103].forEach((id) => save(id)); ``` **Keep extra parameters off the public signature.** If `retries` is an internal knob, an options object makes accidental binding impossible, since no callback API passes an object in the second position by convention: ```ts function save(id: number, opts: { retries?: number } = {}) {} ``` **Treat point-free style as a decision, not a default.** Passing a function by name is fine when you have checked its arity against what the API supplies; it is a hazard when the function is one someone else may add a parameter to later. Adding a second parameter to a widely-used function is a change that can alter behaviour at every point-free call site without producing a single type error. ## What you should not conclude Do not conclude that the rule is a flaw to be worked around with wrappers everywhere. The alternative — requiring exact arity — would force noise into every callback in the language and would reject correct code far more often than it catches this bug. The right response is to know the rule, recognise the point-free hand-off as the place it bites, and be deliberate about the signatures of functions that are passed by reference. ## Common mistakes - Believing extra arguments are dropped before the call. They are passed; the function merely ignores the ones it did not declare. - Expecting a default value to protect a trailing parameter from a supplied argument. - Thinking the compiler will flag a point-free hand-off with mismatched arity. It is type-correct by design. - Getting the direction backwards and claiming more parameters are allowed while fewer are not.

  • Why is the opposite direction — assigning a function with more parameters — rejected?
    Because the target's callers would not supply those arguments. A parameter declared `index: number` would be bound to `undefined` at runtime while the type says it is a number, and the body would use it as such. Allowing that would be unsound, so the compiler rejects it.
  • Would adding a second parameter to an existing widely-used function be caught by the type checker?
    Not at point-free call sites. Every place the function is passed by name into a higher-order API still type-checks, but the new parameter now silently receives whatever that API supplies in the second position. Only call sites that invoke it directly are re-checked against the new arity.
  • Is wrapping every callback in an arrow function the right general policy?
    No — it would add noise to a language whose whole callback style depends on the fewer-parameters rule. Wrap where the function you are passing has extra parameters, especially defaulted ones, or where it is a shared function someone may extend later. Elsewhere, passing by name is clear and safe.

saying these in an interview costs you the question

  • Says extra arguments are stripped before the call
  • Gets the direction backwards — more parameters allowed, fewer rejected
  • Claims a default value protects a trailing parameter
  • Expects the compiler to flag a point-free hand-off
  • Calls the rule a compiler bug rather than a deliberate design

context