Optional chaining and nullish coalescing in JavaScript are two operators built for one situation: a value that might not be there. The optional chaining operator ?. lets you walk into an object without checking every step, and the nullish coalescing operator ?? supplies a fallback when a value is missing. Used together they turn a wall of defensive checks into two characters, and used carelessly they turn a crash into a silent blank — which is the trade you have to understand before you rely on them.
What problem are optional chaining and nullish coalescing solving?
Data that arrives from an API has an optional shape. An order might have a shipping address, a user might have a profile picture, a settings object might have a theme. Every one of those paths is a place where the property you want is simply not there, and the traditional answer is a guard before every access.
const order = {
id: 'A-1024',
customer: { name: 'Ada', address: { city: 'London' } }
};
console.log(order.customer?.address?.city);
console.log(order.shipping?.address?.city);
console.log(order.shipping?.address?.city ?? 'no shipping address');
The first line reads the city, which exists. The second line asks for a shipping address that does not exist on this object and gets undefined back instead of a TypeError. The third line adds the fallback. Both operators treat exactly two values as missing: null and undefined. A property holding 0 or an empty string is a real value and comes through untouched, which is the behaviour you want for a price of zero or an empty field a user deliberately cleared.
How do you call or index something that may be missing?
Plain ?. handles property access. Two more forms handle the other two cases: ?. followed by parentheses calls a function that might not exist, and ?.[ with brackets looks up a key that might not be there.
const config = {
onSave: () => 'saved',
labels: { title: 'Report' }
};
console.log(config.onSave?.());
console.log(config.onCancel?.());
console.log(config.labels?.['title']);
console.log(config.labels?.['subtitle'] ?? 'untitled');
config.onSave?.() calls the function and prints saved. config.onCancel?.() finds no such property, so the call never happens and the expression evaluates to undefined — no error, and nothing runs. The bracket form is the one to reach for when the key is computed rather than written out, such as labels?.[fieldName], because a dot after a computed key is not valid syntax.
One rule about the optional chain: it cannot be the target of an assignment. Writing config?.theme = 'dark' is rejected by the parser, because there is no coherent meaning for assigning to something that might not exist. Deleting is different — delete config?.theme is allowed and does nothing when the object is missing.
Where exactly does an optional chain stop?
This is the detail that catches people, and it is worth being precise: the chain short-circuits at the first nullish value it meets, and everything to the right of that point is skipped entirely.
const order = null;
console.log(order?.customer.address.city);
try {
console.log(order.customer?.address.city);
} catch (err) {
console.log(err.constructor.name + ': ' + err.message);
}
The first line is safe even though customer.address.city contains no question marks of its own, because the chain broke at order and never evaluated any of it. The second line throws, and the reason is the order of operations: order.customer is evaluated before the engine reaches the ?., so the missing object is dereferenced first and the question mark arrives too late to help. Put the optional chaining at the point where the value can actually be missing.
A chain that short-circuits produces undefined, not null, so code that checks for null alone will not catch it.
How does ?? differ from ||?
The two look similar and behave differently on the values that matter. The logical or operator falls back whenever the left side is falsy, and falsy includes zero, the empty string and false. The nullish coalescing operator falls back only for null and undefined, so genuine values survive.
const settings = { retries: 0, timeout: null, label: '' };
console.log(settings.retries ?? 3);
console.log(settings.retries || 3);
console.log(settings.timeout ?? 5000);
console.log(settings.label ?? 'none');
console.log(settings.label || 'none');
const options = {};
options.theme ??= 'dark';
console.log(options.theme);
options.theme ??= 'light';
console.log(options.theme);
Compare the first two lines. The retry count is zero, and zero is exactly the value a caller would care about, so ?? keeps it while || throws it away and substitutes three. The empty label behaves the same way. The last four lines use the assignment form: ??= assigns only when the current value is nullish, so the first call sets the theme to dark and the second leaves it alone.
| Value on the left | ?? gives | || gives |
|---|---|---|
0 | 0 | the right-hand value |
'' | the empty string | the right-hand value |
false | false | the right-hand value |
null | the right-hand value | the right-hand value |
undefined | the right-hand value | the right-hand value |
What are the rules for mixing ?? with other operators?
You cannot write ?? next to || or && without parentheses. The engine refuses to guess which one you meant, because the two operators do different things and a silent choice would change the meaning of your code. The same applies to mixing ?? with &&, and the same applies to writing them together in a longer expression such as a ?? b || c.
const payload = { results: ['a', 'b'] };
console.log(payload?.results ?? []);
console.log(payload?.result ?? 'no results');
try {
new Function('a ?? b || c');
} catch (err) {
console.log(err.constructor.name + ': ' + err.message);
}
The fix is to add the brackets that make your intent explicit: (a ?? b) || c. It looks redundant, and it is, but it also documents which rule applies where, and it is what the engine is asking for.
The first two lines of that snippet are worth reading twice. The first asks for a field that exists and gets the array. The second asks for a field that does not exist — a typo, or a version of the API that renamed it — and gets the fallback instead of an error. That is the convenience and the danger in one example.
When does optional chaining hide a real bug?
Every question mark you add is a decision not to be told when something is missing. For data that is genuinely optional — a middle name, a theme colour, a shipping address that not every order has — that is the right decision, and the fallback is a sensible default. For data that must be there, it is a bad one. A required configuration section, a response body you are about to iterate over, an id you are about to look up in a database: when those are missing, something has gone wrong, and the useful outcome is an error you can see rather than an empty list you have to debug later.
| Situation | What to write | Why |
|---|---|---|
| an optional field on a user | user?.nickname ?? 'anonymous' | the field is often missing and the default is meaningful |
| a required section of a response | response.data.items | a missing section is a bug, and you want it to be loud |
| a computed key on an optional object | labels?.[key] ?? 'none' | the key may not exist at all |
| a callback that may not be supplied | onDone?.(result) | not calling it is a legitimate outcome |
A practical rule: use optional chaining where the absence of a value is a normal state of the world, and use a plain property access where the absence of a value is a fault. If you find yourself writing a chain six levels deep with a fallback at the end, ask yourself whether the shape is really that optional — often the better fix is a default applied once, near the top, rather than a fallback pasted onto every line.
Try it yourself: take one small API response and decide, field by field, which parts may legitimately be missing and which must not be. Start with { user: { name: 'Ada', avatar: null, address: { city: 'London' } }, meta: { page: 1 } }. Write a function that reads the display name with optional chaining and a fallback, and check that it prints Ada and that it prints anonymous when the name key is deleted. Then write a second function that reads meta.page with a plain property access and confirm it throws once meta is removed — decide whether that crash is the behaviour you want for a paginated list. Finally, convert that second function to optional chaining with a fallback of 0, and work out how you would tell the difference between a page that really is zero and a page that was never sent.