Ch. 1 · JavaScript

JavaScript Object Destructuring and Defaults

When JavaScript destructuring defaults apply (only for undefined), why options = {} matters, and how to pull nested fields without crashing.

~6 min readbeginnerupdated Oct 19, 2026

Destructuring with defaults is one of the nicest pieces of ES2015 syntax. A function can declare its options, their default values and their names in a single line, and the body reads cleanly. It also packs several rules into very little syntax, and each rule has a failure mode: a default that doesn’t apply when you expected it to, a call with no arguments that throws, a nested pattern that crashes on a partial API response.

This guide goes through those rules with an example you’d write in real code, a database connection helper, and then covers nesting, renaming and the order in which defaults are evaluated.

Defaults fill in undefined, and nothing else

Here’s an options function in the usual shape:

function connect({ host = 'localhost', port = 5432, ssl = false } = {}) {
  return `${host}:${port} ssl=${ssl}`;
}

console.log(connect());                    // localhost:5432 ssl=false
console.log(connect({ port: 6543 }));      // localhost:6543 ssl=false
console.log(connect({ port: undefined })); // localhost:5432 ssl=false
console.log(connect({ host: null }));      // null:5432 ssl=false
console.log(connect({ ssl: 0 }));          // localhost:5432 ssl=0
JavaScript

The rule is strict: a default is used when the property’s value is undefined, whether because the property is missing or because it was explicitly set to undefined. Any other value, including null, 0, false and the empty string, is kept as-is.

So host: null produces "null:5432". That’s the bug interviewers want you to spot. It turns up when options come from JSON or a database, where “no value” is represented as null rather than a missing key. A config file containing { "host": null } overrides your default with null, and the connection fails with an error that mentions a host called null.

This is the same rule as default function parameters and the nullish-coalescing operator, almost. ?? treats both null and undefined as missing, while destructuring defaults only treat undefined as missing. If your data can contain null, apply ?? inside the function:

function connect(options = {}) {
  const host = options.host ?? 'localhost';
  const port = options.port ?? 5432;
  // ...
}
JavaScript

The other direction is the reason destructuring defaults are often better than ||. With const timeout = options.timeout || 1000, passing timeout: 0 (meaning “no timeout”) gets replaced with 1000. With const { timeout = 1000 } = options, the zero survives: const { timeout = 1000 } = { timeout: 0 } gives 0.

Why the options parameter needs = {}

Look at the = {} after the pattern in connect. It’s a default for the whole parameter, and it’s easy to leave out:

function noDefault({ host = 'x' }) { return host; }
noDefault();
// TypeError: Cannot read properties of undefined (reading 'host')
JavaScript

Destructuring needs an object to read from. Calling noDefault() passes undefined for the parameter, and the engine tries to read host from undefined. The inner default host = 'x' never gets a chance, because it applies to the property, not to the object that should contain it.

With = {}, a missing argument becomes an empty object first, and then each property default applies. That’s why the pattern ({ ... } = {}) appears in almost every options-taking function in well-written libraries.

The error text depends on the situation. Destructuring null in a declaration gives a more specific message in V8:

const { z } = null;
// TypeError: Cannot destructure property 'z' of 'null' as it is null.
JavaScript

Note that the parameter default doesn’t save you from null either: connect(null) throws, because = {} only replaces undefined. If callers might pass null, accept options as a plain parameter and destructure options ?? {} in the body.

Nested patterns and partial responses

Destructuring can reach several levels deep, which is convenient for API responses:

const res = { data: { user: { name: 'Ana' } } };
const { data: { user: { name, email = 'n/a' } } } = res;
console.log(name, email);  // Ana n/a
JavaScript

Read the pattern from the outside in: from res take data, from that take user, from that take name and email. Only name and email become variables. data and user are just path segments; a common surprise is that data isn’t defined after this line.

Now give it a response where user is missing, which is exactly what you get when a user is logged out or a field is behind a permission check:

const { data: { user: { name } } } = { data: {} };
// TypeError: Cannot read properties of undefined (reading 'name')
JavaScript

The default on email doesn’t help, because the failure happens one level up: user is undefined, and you can’t destructure name out of it. Every level that might be absent needs its own fallback:

const { data: { profile: { city } = {} } = {} } = { data: {} };
console.log(city);  // undefined
JavaScript

That works, but it’s hard to read, and it gets worse with each level. Past two levels, optional chaining is usually clearer: const city = res.data?.profile?.city. Destructuring is best for pulling several fields out of an object you know exists; optional chaining is best for walking down a path where any link might be missing.

Renaming, rest and lazy defaults

Three more pieces of the syntax come up regularly.

Renaming uses a colon, and a default can follow the new name:

const { id: userId, 'first-name': firstName = 'anon' } = { id: 7, 'first-name': undefined };
console.log(userId, firstName);  // 7 anon
JavaScript

The colon reads the opposite way from an object literal: id: userId means “take property id, put it in variable userId”. Mixing up the direction is common, and const { userId: id } = obj silently gives you undefined because obj has no userId. Quoted keys work for property names that aren’t valid identifiers.

Rest collects the remaining own enumerable properties into a new object. It’s a tidy way to strip a field before logging or sending a response:

const { password, ...safe } = { id: 1, name: 'Ana', password: 'x' };
console.log(safe);  // { id: 1, name: 'Ana' }
JavaScript

Linters will flag password as unused; that’s expected for this pattern, and most configs have an option to ignore rest siblings.

Defaults are lazy and ordered. A default expression runs only when it’s needed, and it runs again every time:

let calls = 0;
const mk = () => (++calls, 'fresh');
const { a = mk() } = { a: 'given' };
const { b = mk() } = {};
console.log(a, b, calls);  // given fresh 1
JavaScript

mk ran once, for b. That laziness means a default can be expensive (or have side effects) without costing anything when a value is provided. It also means { tags = [] } = {} creates a fresh array on each call. Python developers will recognise why that matters: in Python, a mutable default is shared between calls, but in JavaScript addTag() called twice returns two separate ['x'] arrays.

Defaults are evaluated left to right, so a later one can use an earlier one:

const { min = 1, max = min * 10 } = { min: 5 };
console.log(min, max);  // 5 50
JavaScript

Assigning to existing variables

To destructure into variables that already exist, wrap the statement in parentheses:

let x;
({ x } = { x: 42 });
JavaScript

Without the parentheses, a statement starting with { is parsed as a block, and { x } = ... is a syntax error. If your code style omits semicolons, put one before the parenthesis, or the previous line may be treated as a function call.

Interview exercise: a settings loader

Question. User settings arrive from the server as JSON like { "theme": null, "pageSize": 0, "notifications": { "email": false } }. Some users have older records with no notifications key at all. Write readSettings(raw) that defaults theme to 'light', pageSize to 25 and notifications.email to true, and doesn’t crash when raw or notifications is missing. What should pageSize: 0 do?

Answer. The JSON uses null for “not set”, so destructuring defaults alone would keep theme: null. Use destructuring to shape the input and ?? for the fallbacks:

function readSettings(raw) {
  const { theme, pageSize, notifications } = raw ?? {};
  return {
    theme: theme ?? 'light',
    pageSize: pageSize ?? 25,
    notifications: { email: notifications?.email ?? true },
  };
}
JavaScript

raw ?? {} handles both a missing and a null payload, and notifications?.email handles old records. email: false survives because ?? only replaces null and undefined; using || here would turn every user’s opt-out back into true, which is a real privacy bug. For pageSize: 0, the code keeps the zero, but zero rows per page is meaningless, so this is the moment to ask what the product wants. A good answer validates it separately (for example, clamp to a range of 10 to 100) rather than hiding the decision inside a default. Pointing out that defaults handle absence while validation handles bad values is what separates a strong answer from a correct one.

Learn more

More in JavaScript

esc