Ch. 1 · JavaScript

JavaScript Optional Chaining and Nullish Coalescing

How ?. short-circuits a whole chain, how ?? differs from ||, and when optional chaining hides a real bug instead of handling missing data.

~6 min readbeginnerupdated Oct 19, 2026

Before ES2020, reading a value three levels down meant writing order && order.customer && order.customer.address && order.customer.address.city. Optional chaining (?.) turned that into order?.customer?.address?.city, and nullish coalescing (??) gave a default operator that doesn’t trample zeros and empty strings. Both are now everywhere, and both get used in ways that hide bugs.

The syntax takes a minute to learn. The judgement takes longer: where a missing value is normal and ?. is right, and where a missing value means something broke and ?. just moves the crash somewhere harder to debug. This guide covers the exact rules first, then that judgement.

How ?. short-circuits

Here’s an order object from a checkout service, with the kinds of gaps real data has:

const order = { customer: { address: null }, items: [], notify: undefined };

console.log(order.customer?.address?.city);  // undefined
console.log(order.items?.[0]?.sku);          // undefined
console.log(order.notify?.('shipped'));      // undefined
JavaScript

?. checks the value on its left. If that value is null or undefined, the whole expression evaluates to undefined and nothing to the right runs. Otherwise it behaves like a normal .. There are three forms: a?.b for properties, a?.[key] for computed access and array indexes, and f?.() for calls.

The rest of the chain is skipped too

The detail interviewers like is that short-circuiting covers the entire rest of the chain, not just the next step:

console.log(order.coupon?.code.toUpperCase());   // undefined
console.log((order.coupon?.code).toUpperCase());
// TypeError: Cannot read properties of undefined (reading 'toUpperCase')
JavaScript

In the first line, order.coupon is undefined, so evaluation stops at ?. and .code.toUpperCase() never runs. You don’t need ?. on every link after the first optional one, as long as the rest is guaranteed to exist when the first part does.

Parentheses end the chain. In the second line, (order.coupon?.code) is a complete expression that produces undefined, and then .toUpperCase() is called on that. This is also why you’ll see code written with ?. everywhere “to be safe”: the author wasn’t sure how far the short-circuit reached.

Side effects in the skipped part don’t happen either:

let n = 0;
const r = null;
r?.[n++];
console.log(n);  // 0
JavaScript

The index expression n++ never ran. It’s a contrived example, but the same thing applies to function arguments: in logger?.info(buildExpensiveMessage()), the message isn’t built when there’s no logger.

What ?. doesn’t do

It only guards the position it’s written in. order.customer.address?.city protects against address being missing, but if order.customer is undefined, reading .address from it still throws. Put the ?. after each value that might be absent.

Optional calls check for null and undefined, not for “is this a function”. If the value exists but isn’t callable, you still get an error:

const o2 = { notify: 'yes' };
o2.notify?.();  // TypeError: o2.notify is not a function
JavaScript

It doesn’t help with undeclared variables. undeclaredThing?.x throws ReferenceError: undeclaredThing is not defined, because the variable lookup fails before ?. gets a value to check. For globals that might not exist, such as window during server-side rendering, use typeof window !== 'undefined' or globalThis.window?.x.

And you can’t assign through it. order.customer?.name = 'x' is a SyntaxError: Invalid left-hand side in assignment. The language designers left this out deliberately: silently skipping a write is more dangerous than silently skipping a read.

?? versus ||

?? returns its right side when the left side is null or undefined, and its left side otherwise. || returns its right side when the left side is any falsy value: null, undefined, 0, -0, NaN, '' and false.

console.log(0 ?? 10, '' ?? 'x', false ?? true);    // 0 '' false
console.log(null ?? 'd', undefined ?? 'd');        // d d
console.log(0 || 10, '' || 'x');                   // 10 x
JavaScript

The difference only matters when a falsy value is legitimate, but in UIs that’s constantly. An unread-message badge shows the count or a dash:

const unread = 0;
console.log(`Unread: ${unread || '-'}`);  // Unread: -
console.log(`Unread: ${unread ?? '-'}`);  // Unread: 0
JavaScript

With ||, a user who has read everything sees a dash, as if the count failed to load. Volume set to 0, a discount of 0%, an empty search string, a checkbox set to false: each one gets overwritten by ||. A reasonable default is to reach for ?? whenever you mean “if missing”, and keep || for when you genuinely want “if falsy”, such as treating an empty display name the same as no display name.

Precedence and the mixing rule

?? can’t be combined with || or && without parentheses:

null || undefined ?? 'x';    // SyntaxError: Unexpected token '??'
(null || undefined) ?? 'x';  // 'x'
JavaScript

The committee made mixing a syntax error because the intended grouping was ambiguous, and people would have guessed differently. Add parentheses to say which you mean.

The assignment forms

ES2021 added ??=, ||= and &&=. a ??= b assigns b only if a is null or undefined, and it doesn’t evaluate b at all otherwise:

const cfg = { retries: 0 };
cfg.retries ??= 3;
cfg.timeout ??= 5000;
console.log(cfg);  // { retries: 0, timeout: 5000 }

const cfg2 = { retries: 0 };
cfg2.retries ||= 3;
console.log(cfg2); // { retries: 3 }
JavaScript

retries: 0 meant “don’t retry”, and ||= replaced it with 3. That’s a behaviour change hidden in two characters. ??= is also a neat way to lazily initialise a cache entry: cache[key] ??= compute(key) runs compute only on a miss.

When ?. hides a bug

Optional chaining makes a missing value produce undefined instead of an error. That’s right when absence is a valid state of the data, and wrong when absence means a bug upstream.

Compare two cases from the same checkout page. order.customer?.address?.line2 is fine: many addresses don’t have a second line, and the template should render nothing. Now consider order?.total?.toFixed(2). An order on the checkout page must exist and must have a total. If it doesn’t, something failed (the fetch, the state update, the API contract), and the right outcome is a loud error close to the cause. With ?., the page renders “Total: “ followed by nothing, the user can click Pay, and the bug report you eventually get says “checkout shows a blank total sometimes”, with no stack trace.

A useful habit: before writing ?., say out loud what it means for that value to be missing. If the answer is “the user hasn’t added one” or “the API omits it for free accounts”, use it. If the answer is “it should never be missing”, don’t; let it throw, or check explicitly and throw an error that says what was expected. TypeScript helps here, since a non-optional type makes the ?. look as unnecessary as it is.

Optional chaining also doesn’t distinguish between null and a missing property, or between “the address is missing” and “the customer is missing”. If those cases need different handling (show “add an address” versus redirect to login), check them separately.

Practice: review this snippet

Question. A code review shows this line in a notification preferences screen. What would you comment?

const emailOn = user?.settings?.notifications?.email || true;
JavaScript

Answer. There are two problems. First, || true means the result is always true: if email is false, false || true is true, so a user who turned email off still shows it as on, and the app may keep emailing them. The fix is ?? true, which keeps an explicit false and only defaults when the value is absent. Second, the chain guards every link, including user. On a preferences screen, the user must be loaded; if it isn’t, the screen should show a loading or error state rather than quietly reporting “email on”. I’d suggest const emailOn = user.settings?.notifications?.email ?? true;, keeping ?. only on the parts a newly created account might not have yet, and confirming with the backend which of those fields can actually be missing. A good review comment explains the user-facing consequence (unwanted emails), not just the operator.

Keep learning

More in JavaScript

esc