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?. 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')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); // 0The 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 functionIt 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 xThe 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: 0With ||, 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'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 }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;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.