The null-safe operator trio: ?., ??, and ??=
A JavaScript foundations lesson on the optional chaining, nullish coalescing, and nullish assignment operators that handle missing values without defensive chains or falsy-coercion bugs.
Two short bugs share a root cause. The first is a defensive chain like if (user && user.profile && user.profile.address) { … }, which the team rewrites once per page until someone reaches for a three-character fix. The second is const pageSize = input.pageSize || 20, which works fine until the day a caller passes pageSize: 0 to mean “show no rows” and your function silently renders twenty rows instead. Both bugs are about a value that might be missing, and both have a precise fix in a trio of operators that has shipped in every browser since 2020.
This lesson covers that trio. ?. is optional chaining, for nullable property and call access. ?? is nullish coalescing, for “use a default when the value is missing.” ??= is nullish coalescing assignment, for lazy initialization. There is also one closing rule the language enforces on purpose: JavaScript rejects ?? mixed with || or && without explicit parentheses, and the fix is one set of parens.
The lesson works through them in order. First ?., with its per-link semantics and the overuse trap that silences the type system. Then ??, with the falsy-versus-nullish distinction behind it and the three concrete bugs || ships. Then ??= for the cache-or-compute pattern, and finally the parens rule for mixed expressions. The previous lesson on “Flat control flow” gave you guard clauses for null and undefined cases; this lesson gives you the operators that handle the same shape inline, with no if, no return, and no extra indentation. It also rests on the same idea as the == ban from the chapter on TypeScript fundamentals: the language distinguishes nullish from falsy on purpose, and you should too.
?. for nullable property and call access
Section titled “?. for nullable property and call access”The first operator collapses the defensive chain from the intro into a single line.
const city = user?.profile?.address?.city;Each ?. checks the value to its left before stepping right. The first nullish link short-circuits the entire expression to undefined, so the chain replaces the nested && and never throws the Cannot read property 'address' of undefined crash at runtime. Optional chaining covers three access shapes, each with its own punctuation: property, method call, and index.
const city = user?.profile?.address?.city;Property access. The chain short-circuits at the first nullish link and the whole expression resolves to undefined. Use this form at any dotted access where the receiver might be missing.
const greeting = user?.greet?.();Method call. The ?.() form checks that user.greet is not nullish before calling. Useful for optional callbacks: an onSuccess?.() prop on a component, a cleanup?.() returned from an effect, a feature-flagged method on a third-party SDK.
const first = invoices?.[0];Indexed access. The ?.[…] form checks invoices before indexing. Use this form at any optional array or object access where bracket notation is required: dynamic keys, numeric indices, anything not expressible as a static dotted path.
Each ?. only guards the link to its left
Section titled “Each ?. only guards the link to its left”One rule governs this section: a ?. at one link doesn’t protect later links. Each operator checks exactly one step, so a single ?. at the front of a chain leaves every later link unguarded.
const city = user?.profile.address.city;This looks defensive but isn’t. The ?. after user only guards the access to profile. If user.profile is nullish, the chain throws at .address, with the same Cannot read property 'address' of undefined you were trying to avoid. The fix is a ?. at every link that could be missing:
const city = user?.profile?.address?.city;Now every step checks its receiver before continuing. That gives the rule: question-dot at every nullable link, not just the first one. When you review code and see a chain with one ?. followed by plain dots, ask whether the types allow the middle links to be nullish. If they do, the chain is one missing field away from a runtime error.
The overuse trap: don’t silence the type system
Section titled “The overuse trap: don’t silence the type system”The opposite reflex causes more trouble. A tempting rule for ?. is to add it anywhere you’re unsure, but that rule is wrong, because every unnecessary ?. silences the type system on its line. Optional chaining is meant to handle the nullables the type acknowledges, while TypeScript is meant to fail loudly when a field that was non-nullable becomes nullable later. Reach for ?. on a field the type says is required, and you turn that future warning off.
type Order = { customer: Customer; lines: Line[];};
const orderTotal = order.customer?.lines.length;customer is required by the type: there’s no ? after it, so its type is Customer, not Customer | undefined. Writing order.customer?.lines reads as cautious, but it isn’t. The day someone marks customer as optional to support draft orders, every existing call site silently returns undefined instead of failing at the line that now needs a second look. The discipline that avoids this is short:
?. where the type acknowledges nullable; let TypeScript fail at the call site otherwise.
The narrowing chapter later in this unit covers the full story of how TypeScript flows nullability through ?. chains. It also explains why the right tool for a “value might or might not be there” branch is often a guard clause that narrows before the access, rather than a ?. after it. For now, learn to recognize the smell: a ?. on a non-nullable field is not extra safety, it is a hidden hole in your type coverage.
The exercise below checks the per-link rule on a realistic chain.
Given the types below, which expression fetches the city without throwing when any nullable link is missing — and without silencing a future type error?
type Address = { city: string };type Customer = { address?: Address };type Order = { customer?: Customer; lines: Line[] };
declare const order: Order;order.customer?.address.cityorder.customer?.address?.cityorder?.customer?.address?.cityorder.customer.address.citycustomer? and address?) carry a ?., and the final .city access reads safely because the chain has already short-circuited to undefined if anything before was missing. The first is the per-link trap — customer?.address guards customer, but the plain .city still throws the moment address is missing. The third is the overuse trap from this section: order is non-nullable per the type, so the leading ?. silences a future signal — the day someone marks order optional, every caller would silently return undefined instead of the compiler pointing at the chain. The fourth is the original defensive-chain bug: no guards at all, throws on the first missing link.?? for “use a default when the value is missing”
Section titled “?? for “use a default when the value is missing””The second operator carries the lesson’s central distinction. The rule fits in one sentence: ?? returns the right operand only when the left is null or undefined, while || returns the right operand for any falsy left. Everything in this section is a consequence of that one difference.
Nullish means the value is genuinely absent: null or undefined. Falsy is a wider set that also counts several legitimate-but-empty values as missing: the empty string, zero, false, and NaN. The two sets overlap on null and undefined, and diverge everywhere else. ?? reacts to the nullish set, || reacts to the falsy set. So when you want a default to fire only when the field is truly absent, ?? is the operator. || fires on absence too, but also on every legitimate zero, empty string, or false the caller might pass, and you almost never want both behaviors at once.
Three concrete bugs || ships
Section titled “Three concrete bugs || ships”Each tab below is a real bug the || reflex produces, and all three have the same shape: the right operand is the “default,” the left operand can legitimately be a falsy value, and || overwrites the value the caller actually meant.
const pageSize = input.pageSize || 20;const pageSize = input.pageSize ?? 20;A caller passing pageSize: 0 means “show no rows,” a legitimate way to request an empty result. || reads 0 as falsy and overwrites it with 20; ?? keeps the zero and only defaults when the field is actually absent.
const displayName = form.name || 'Anonymous';const displayName = form.name ?? 'Anonymous';An empty string is often a value the user deliberately cleared, such as a form field they emptied before submit. || treats it as missing; ?? respects the user’s choice and only defaults when the field wasn’t submitted at all.
const enabled = settings.enabled || true;const enabled = settings.enabled ?? true;A boolean literal on the right is the clearest sign that || is the wrong operator here. If the left is also boolean, falsy-vs-nullish is the entire question, and || will overwrite every deliberate false the user sets.
The whole table, by hand
Section titled “The whole table, by hand”The quickest way to fix the distinction in your head is to work the full table by hand. The program below runs six inputs, five falsy values and one ordinary string, through both || and ??, which gives twelve outputs. The three rows where the operators disagree are the three bugs from the tabs above.
Predict every line this program prints. Six inputs crossed against `||` and `??` — twelve lines total. The rows where the two operators diverge are the four bugs `||` ships and `??` fixes. Predict what this program prints, then press Check.
const cases = [0, '', false, null, undefined, 'value'];for (const v of cases) { console.log(`${JSON.stringify(v)} || 'D' = ${v || 'D'}`); console.log(`${JSON.stringify(v)} ?? 'D' = ${v ?? 'D'}`);}0, '', and false. For each of those, || reads the left as falsy and substitutes the default, while ?? reads the left as non-nullish and keeps it. The other three rows (null, undefined, 'value') the two agree on — which is why the bug stays invisible in casual reading. It only fires when a caller deliberately passes a legitimate falsy value, which is exactly when the user’s intent matters most.The senior rule, restated
Section titled “The senior rule, restated”For “use a default when the value is missing,” reach for ??. Reach for || only when “any truthy value short-circuits” is exactly what you want. In 2026 SaaS code that second case is rare, so the senior writes ?? by reflex and treats every || as a deliberate choice. The same semantic backs the parameter-defaults rule from “Signatures that stay readable past two parameters”: parameter defaults fire only on undefined. ?? is that same “the field is missing” behavior, available as an operator anywhere in the body of a function.
??= for lazy initialization
Section titled “??= for lazy initialization”The third operator follows directly from the second: x ??= y assigns y to x only when x is currently nullish. It applies the same nullish-not-falsy semantic as ?? to assignment. The canonical use is the cache-or-compute pattern, expressed in three characters.
const cache: Record<string, number> = {};
const getOrCompute = (key: string) => { cache[key] ??= computeExpensive(key); return cache[key];};The line does one read and one conditional write. The first call for a key writes the slot, and every later call for that key reads the cached value. The ??= form says “fill this slot if it’s empty” directly, without spelling out an explicit if (cache[key] === undefined) cache[key] = … or the more verbose cache[key] = cache[key] ?? … version of the same idea. Reach for it in any “initialize once, reuse” pattern that doesn’t need a full memoization library.
The right-hand side is evaluated lazily
Section titled “The right-hand side is evaluated lazily”??= only evaluates its right operand when the left is nullish, so computeExpensive(key) doesn’t run on cache hits. When the right-hand side is a function call, an API request, or a database read, that laziness does real work: it skips the call entirely when the slot is already filled. The naive long-hand form cache[key] = cache[key] ?? computeExpensive(key) happens to behave the same way, because ?? itself short-circuits, but it reads the cache twice and writes unconditionally. ??= states the same intent with one read and one possible write.
||= and &&= exist, but you write ??=
Section titled “||= and &&= exist, but you write ??=”Two sibling operators exist: ||= assigns when the left is falsy, and so ships the same 0/''/false traps as ||; &&= assigns when the left is truthy. Both are valid JavaScript, and both occasionally fit a real pattern. The course writes ??= by reflex because nullish-not-falsy is almost always what the caller wants, the same reasoning that picked ?? over ||. Knowing ||= and &&= exist is enough to read them in other people’s code; for “fill if empty,” write ??=.
Put the trio together
Section titled “Put the trio together”This exercise puts the operators to work together. The function below reads a deeply optional configuration object and returns a normalized { pageSize, theme } shape. The traps from the ?? section are baked into the tests: the function must treat 0 as a legitimate pageSize and '' as a legitimate theme, not as missing values to overwrite with defaults. The || reflex fails both tests, and the ?? reflex passes them.
Implement getConfig(input). Read input.user.preferences.pageSize and default to 20 when the field is missing — treat 0 as a legitimate user choice, not as 'missing'. Read input.user.preferences.theme and default to 'light' when missing — treat '' as a legitimate cleared value. Return { pageSize, theme }. The expected solution uses ?. for the nested access and ?? for the two defaults.
Mixing operators: the parens rule
Section titled “Mixing operators: the parens rule”One mechanic remains before the lesson closes. JavaScript intentionally rejects ?? mixed with || or && when there are no explicit parentheses. a || b ?? c is a syntax error, not a precedence quirk you can memorize away.
const value = a || b ?? c;The language did this because the precedence is genuinely ambiguous to readers: does || bind tighter than ??? Does ?? bind tighter than &&? Rather than pick answers and let half the readers get them wrong, the spec authors required explicit grouping. The error is the language refusing to guess at the author’s intent. The fix is one set of parens, placed around whichever grouping you meant:
const value = (a || b) ?? c;
const value = a || (b ?? c);The two groupings mean different things. The first evaluates the || first: pick the truthy of a or b, then fall back to c only if the result is nullish. The second evaluates the ?? first: use a when truthy, otherwise compute b ?? c, which is b when not nullish and c when it is. The same operators carry two meanings, and the parser refuses to pick one without you saying so.
The broader parens reflex
Section titled “The broader parens reflex”You don’t memorize JavaScript’s full operator-precedence table; there are too many operators, and remembering them costs more than typing parens. The senior reflex is simpler: when an expression mixes operators of different kinds, add parens that state the intent. Even when the parser doesn’t require them, the parens document the author’s reading for the next reviewer.
const score = a ?? b + 1;
const score = (a ?? b) + 1;The first line parses as a ?? (b + 1), which is almost certainly not what was meant. The second line says the intent out loud: take a if it’s set, otherwise b, then add one. The parens cost nothing; the misread precedence costs a runtime bug that’s invisible in casual review.
The rule generalizes: any time ?? shares an expression with ||, &&, +, -, or any other binary operator, parens make the grouping explicit. The parser accepts both readings; your job is to pick the one you meant.
External resources
Section titled “External resources”The canonical reference for `??` — operator behavior, the falsy-vs-nullish table, and the mixing-without-parens rule this lesson installs.
The companion reference for `?.` — property, call, and indexed access forms, plus the per-link short-circuit semantics.
An alternative explainer with worked examples for `??`, the OR-vs-coalesce comparison, and the parens-required rule for mixed expressions.
The Biome rule that nudges defensive `&&` chains toward `?.` with an autofix. The build-time backstop for the `?.` reflex.