skip to content

In JavaScript, when are default parameter expressions evaluated, and can one default refer to another parameter?

level: middleimportance: should knowfreq 42%

answer

  1. not baked in at definition
  2. one at a time, in order
  3. each call gets its own
  4. look left, never right
  5. reading a later parameter is a TDZ error

basics

~20 s

Default expressions are evaluated at call time, lazily, left to right, and only for parameters that are undefined. A default may reference parameters to its left; referencing one to its right throws a ReferenceError because that binding is still in its temporal dead zone.

solid answer

~50 s

Defaults are not baked in at definition time — each initialiser is an expression evaluated during the call, in parameter order, and only when that parameter's value is `undefined`. Because evaluation is left to right, a later default can use an earlier parameter: `function slice(str, start = 0, end = str.length)` works. Going the other way fails: `function f(a = b, b = 2)` throws a `ReferenceError` when called as `f()`, since `b` is not yet initialised. Per-call evaluation also means `function collect(item, into = [])` allocates a fresh array on every call that omits it, so calls never share state. One structural detail worth knowing: parameters with initialisers live in their own scope, distinct from the body, so a default resolves outer bindings rather than same-named `let` or `var` declarations inside the function body.

code

javascript · 14 lines
javascript
function cut(text, start = 0, end = text.length) {
  return text.slice(start, end);
}
console.log(cut('abcdef', 2)); // 'cdef'

function broken(a = b, b = 2) {
  return [a, b];
}
console.log(broken(1)); // [1, 2]
try {
  broken(); // ReferenceError: Cannot access 'b' before initialization
} catch (e) {
  console.log(e.constructor.name); // ReferenceError
}

go deeper

for a junior

Remember that a default is an expression run at call time, not a fixed constant, and that a later default can use an earlier parameter — for example an end position defaulting to a string's length.

for a middle

Explain the ordering precisely: bindings are created uninitialised, then filled left to right, so reading a parameter to the right throws a ReferenceError at call time. Add that each call re-evaluates the expression, so mutable defaults are not shared.

for a senior

Point at the real consequence — a signature with a backward reference or a heavy initialiser passes review and only fails on the code path that omits the argument. Say how you would catch that: a test that calls with no arguments, not just the happy path.

for a principal

Own the readability tradeoff: defaults that call functions or depend on sibling parameters put logic in the signature, where it is easy to miss. Set a team line on how much computation belongs in a parameter list versus an explicit normalisation step at the top of the body.

## Defaults are expressions, not constants Whatever follows `=` in a parameter list is an arbitrary expression: a literal, a function call, a template string, another parameter. The engine does not evaluate it when the function is defined. It evaluates it during *each call*, and only for the parameters whose bound value is `undefined`. ```js function log(message, at = Date.now()) { return `${at}: ${message}`; } ``` Every call that omits `at` gets a new timestamp. If defaults were captured at definition time, every log line would carry the module's load time instead. ## Lazy: skipped when an argument is supplied Evaluation is lazy in the ordinary sense — the initialiser does not run at all when the caller passes a value. ```js let calls = 0; const expensive = () => { calls += 1; return 42; }; function f(x = expensive()) { return x; } f(1); // calls === 0 f(); // calls === 1 ``` This is what makes the "required argument" idiom safe: a default that throws only throws on the calls that omit the argument. ## Fresh values per call Because the expression re-runs, mutable defaults are not shared across calls: ```js function collect(item, into = []) { into.push(item); return into; } collect(1); // [1] collect(2); // [2] — a different array ``` Each invocation that omits `into` allocates its own array. Sharing only happens if the caller passes the same array twice, which is then the caller's explicit choice. ## Left to right, and the temporal dead zone Parameters are initialised in the order they are written. Each one's binding exists but is *uninitialised* until its turn, exactly like a `let` declaration before its line. So a default may read anything already initialised to its left: ```js function cut(text, start = 0, end = text.length) { return text.slice(start, end); } cut('abcdef', 2); // 'cdef' — end saw text ``` and throws if it reads to its right: ```js function f(a = b, b = 2) { return [a, b]; } f(); // ReferenceError: Cannot access 'b' before initialization f(1); // [1, 2] — a is supplied, so its initialiser never runs ``` Note the second line: the error is a *runtime* error on the calls that actually evaluate the bad initialiser, not a syntax error at parse time. A function with this defect can sit in a codebase looking fine until someone omits the first argument. ## The parameter scope is separate from the body scope When any parameter has an initialiser, the parameter list gets its own scope, and the body's declarations sit in a child scope. A default therefore cannot see a `let`, `const` or `var` declared in the body — it resolves the outer binding instead: ```js let mode = 'outer'; function f(m = mode) { let mode = 'inner'; return m; } f(); // 'outer' ``` The converse is that a default can close over a parameter and produce a function that keeps seeing it: ```js function make(a, getA = () => a) { a = 'changed'; return getA(); } make('original'); // 'changed' in most engines — the closure captures the parameter binding ``` That second example is genuinely obscure; the useful takeaway is the first — defaults read the *enclosing* scope, not the body. ## A related syntax rule A parameter list containing any default, rest parameter or destructuring pattern is *non-simple*, and a non-simple parameter list forbids a `'use strict'` directive in the function body: ```js function f(a = 1) { 'use strict'; // SyntaxError } ``` The reason is precisely that parameter initialisers are evaluated in their own scope before the body's directive would take effect. In modules and class bodies, which are already strict, the question never arises. ## How to answer Say "at call time, lazily, left to right". Give the working forward reference (`end = str.length`) and the failing backward one (`a = b, b = 2` → ReferenceError). Add the per-call freshness point, because that is the one with the most practical consequence: mutable defaults are safe in JavaScript in a way people coming from other ecosystems often do not expect.

  • Why is the backward reference a ReferenceError at call time rather than a SyntaxError when the function is parsed?
    Because parameter bindings are created up front but left uninitialised until their own initialiser runs — the same temporal-dead-zone model as `let`. The engine cannot know at parse time whether that initialiser will ever be evaluated, since it only runs when the argument is `undefined`. So `f(1)` is fine and `f()` throws, and the defect can hide until a caller omits the first argument.
  • Can a default parameter call a function declared later in the same module?
    Yes, if it is a function *declaration*, because declarations are hoisted and initialised before any call happens — and the default is evaluated at call time, long after the module finished evaluating. A `const` arrow declared after the function is also fine at call time, but would throw if the function were called during module evaluation before that line ran.
  • Does the per-call evaluation have any performance cost worth caring about?
    Rarely. A default that allocates a small object or reads a length is negligible, and it only runs on calls that omit the argument. If a default performs genuinely expensive work — a network-adjacent lookup, a large allocation — hoist it to a module-level constant and reference that instead, which also makes the sharing explicit rather than accidental.

saying these in an interview costs you the question

  • Says defaults are evaluated once when the function is defined
  • Claims a mutable default object is shared across calls
  • Expects a default to see a parameter declared after it
  • Thinks the backward reference is caught at parse time
  • Believes a default can read a let declared in the function body

context