JAVASCRIPT / FUNCTIONS
Default parameters and the undefined trigger
Write default parameters knowing that only undefined triggers them and that each default is re-evaluated on every call that needs it.
What you will learn
- Tell apart undefined, null, and falsy arguments when deciding if a default applies
- Reference an earlier parameter inside a later parameter's default expression
- Replace `x = x || d` fallbacks so that 0, empty string, and false survive
- Give a destructured options parameter its own `= {}` so a bare call still works
Understanding Default parameters and the undefined trigger
A default parameter is written as an assignment inside the parameter list: `function retry(times = 3)`. On each call the engine binds the parameters, then asks one question about every parameter that has a default — is this value `undefined`? That is a strict identity check, not a truthiness check, which is why omitting an argument and explicitly passing `undefined` behave identically: both leave the binding at `undefined`. Meanwhile `null`, `0`, `''`, `false`, and `NaN` are all real values that arrived, so they win and the default never runs.
The useful mental model is that the default is a line of code spliced in at the top of the call: `if (times === undefined) times = 3;`. Because it is code, it runs at call time rather than at definition time, and only on the calls that actually need it. That is why `function f(list = [])` hands every caller a fresh array instead of one shared array, and why an expensive `loadConfig()` sitting in a default position costs nothing on calls that supply the argument. Languages that evaluate defaults once at definition time have a shared-mutable-default trap; JavaScript does not.
Parameters are initialized left to right in a scope of their own, so a default can read the parameters to its left — `function range(start, end = start + 10)` works — but reading one to its right throws a `ReferenceError`, because that binding is not initialized yet. The same ordering explains why defaulted parameters belong at the end of a signature: a caller cannot skip a middle argument, only pass `undefined` in its place. Keeping the fallback in the signature also makes it visible to anyone reading the call signature, unlike the older `times = times || 3` line buried in the body, which had the additional bug of rejecting `0`.
Defaults compose with destructuring, which is where the `undefined` rule matters most in real code.
function greet(name = "guest", greeting = `Hello, ${name}`) {
return `${greeting}!`;
}
console.log(greet());
console.log(greet("Ada"));
console.log(greet(undefined, "Welcome back"));
console.log(greet(null));
console.log(greet(""));A default parameter is an expression that runs on each call where that parameter's value is exactly undefined — falsy values and null do not trigger it.
Worked examples
Defaults run per call, not per definition
Shows that a default expression is evaluated only on the calls that omit the argument, and evaluated afresh each time.
let calls = 0;
function tag(label = `item-${++calls}`, bucket = []) {
bucket.push(label);
return bucket.join(",");
}
console.log(tag());
console.log(tag());
console.log(tag("fixed"));
console.log("evaluations:", calls);Example explained
Line 1`++calls` sits inside the default, so the counter advances once per defaulting call, not once per definition.
Line 2`tag("fixed")` supplies label, so that whole expression is skipped and `calls` stays at 2.
Line 3Each call re-evaluates `bucket = []`, which is why every result holds exactly one entry instead of accumulating.
Line 4The final line proves the default expression ran twice across three calls.
undefined, null, and falsy side by side
Compares a signature default, an || fallback, and ??= against the same three arguments.
function volumeDefault(level = 5) {
return `default -> ${level}`;
}
function volumeOr(level) {
level = level || 5;
return `|| -> ${level}`;
}
function volumeNullish(level) {
level ??= 5;
return `?? -> ${level}`;
}
for (const arg of [0, null, undefined]) {
console.log(volumeDefault(arg), volumeOr(arg), volumeNullish(arg));
}Example explained
Line 1`volumeDefault(0)` keeps 0 because the trigger is an identity check against undefined, and 0 is not undefined.
Line 2`volumeOr(0)` returns 5 because `0 || 5` only inspects truthiness — the exact bug default parameters remove.
Line 3`level ??= 5` fires for both null and undefined, so it is the tool to reach for when callers send null for "no value".
Line 4The last row is the only one where all three strategies agree, because the argument really is undefined.
Defaulting a whole options object
Shows why a destructured parameter needs its own default before the function can be called with no argument.
function connect({ host = "localhost", port = 5432, secure = false } = {}) {
return `${secure ? "https" : "http"}://${host}:${port}`;
}
console.log(connect());
console.log(connect({ port: 8080 }));
console.log(connect({ host: "db.internal", secure: true }));
function strict({ host = "localhost" }) {
return host;
}
try {
strict();
} catch (err) {
console.log(err.name);
}Example explained
Line 1The trailing `= {}` is what makes `connect()` legal: the missing argument is undefined, so the empty object stands in and each property default then applies.
Line 2`connect({ port: 8080 })` sets one property; host and secure are still undefined on that object, so their defaults fire independently.
Line 3`strict` has property defaults but no default for the parameter itself, so reading `host` off undefined throws a TypeError before the body runs.
Important notes
`fn.length` stops counting at the first defaulted parameter, so `(function (a, b = 1, c) {}).length` is 1 — any library that branches on arity will see fewer parameters than you wrote.
Defaults are initialized left to right, so `function f(a = b, b = 2)` throws a ReferenceError at the moment that default actually runs, while `f(1)` never evaluates it and works fine.
Common mistakes
Assuming the default replaces any falsy argument: with `function slice(start = 1)`, the call `slice(0)` keeps 0, so code that relied on the 1 silently reads from the wrong position.
Feeding default-carrying functions data from JSON, where absent fields arrive as `null`; the default is skipped and the body runs with `null` until something like `name.toUpperCase()` throws.
Putting a defaulted parameter before a required one, as in `function join(sep = ",", items)`; now `join(list)` binds the array to `sep`, and anyone wanting the default must pass `undefined` explicitly.
Try it yourself
Change, predict, then run
Write `formatPrice(amount, currency = "USD", decimals = 2)` that returns `` `${currency} ${amount.toFixed(decimals)}` ``, then log `formatPrice(0)`, `formatPrice(5, undefined, 0)`, and `formatPrice(5, null)`. For each call, note which defaults fired and why.
Open the JavaScript workspaceCheck your understanding
A function is declared as `function log(msg, time = Date.now()) { return time; }`. A caller runs `log("a")` at 10:00 and `log("b", null)` at 10:05. What are the two returned time values?
- The 10:00 timestamp for the first call, and null for the second
- The same timestamp for both, fixed when the function was defined
- The 10:00 timestamp for the first and the 10:05 timestamp for the second, since null means "missing"
- The first call throws, because Date.now() cannot be called from a parameter list
Show answer
`Date.now()` is an expression evaluated during the call that needs it, so the first call captures 10:00 rather than a definition-time value. The second call passes `null`, which is a present value and not `undefined`, so the default is skipped and `time` is `null`. Option 3 is tempting because `null` reads like "nothing there", but the trigger is an identity check against `undefined` alone.