Quick Answer

?? returns the right-hand side only when the left side is null or undefined, while || returns it for any falsy value including 0, empty string, false and NaN. That is why || corrupts defaults for numeric and text fields. ?. stops a property chain and returns undefined instead of throwing when the value before it is null or undefined, and it also works for calls with ?.() and index access with ?.[].

The default value that eats zero

Here is the pattern almost every JavaScript developer writes before learning better:

const student = { name: 'Riya', marks: 0, city: '', active: false };

console.log(student.marks || 100);   // 100   wrong
console.log(student.city  || 'Pune'); // 'Pune' wrong
console.log(student.active || true);  // true   wrong

Each of those values is real data. The student genuinely scored zero, genuinely cleared the city field, and the account is genuinely inactive. || does not ask "is this missing?", it asks "is this falsy?", and JavaScript has exactly eight falsy values: false, 0, -0, 0n, "", null, undefined and NaN. Everything else, including "0", [] and {}, is truthy.

The consequences are not theoretical. A fee field that should read 0 displays the default 500. A discount of zero percent becomes ten percent. A quantity of zero in a cart becomes one. A user who deliberately blanks their bio gets the old placeholder text back on the next save, and the API stores it, so the data is now wrong in the database too. These bugs are hard to spot in review because value || fallback looks like careful defensive code.

The fix is one extra character:

console.log(student.marks ?? 100);    // 0
console.log(student.city  ?? 'Pune'); // ''
console.log(student.active ?? true);  // false

?? is the nullish coalescing operator. It only treats null and undefined as "missing". That is almost always what you meant when you wrote a default, because null and undefined are what a missing key, an absent API field or an uninitialised variable actually give you.

The one time || is still right is when a falsy value genuinely should be replaced, for example const label = input.trim() || 'Untitled', where an empty string really does mean "no title given". Write that deliberately, not by habit.

?? and ??=, plus the parenthesis rule

?? short circuits like || does. If the left side is not nullish, the right side is never evaluated, so an expensive fallback costs nothing when it is not needed.

const port = process.env.PORT ?? computeDefaultPort(); // not called if PORT is set

There is a matching assignment form, ??=, which only writes when the current value is nullish:

const config = { retries: 0 };

config.retries ??= 3;    // stays 0, because 0 is not nullish
config.timeoutMs ??= 5000; // becomes 5000, the key was undefined

console.log(config); // { retries: 0, timeoutMs: 5000 }

Compare with config.retries ||= 3, which would overwrite the deliberate zero. The same trap, one layer deeper.

Now the rule people trip over. You cannot mix ?? with || or && at the same level without parentheses. This is not a lint warning, it is a syntax error and the file will not run at all:

// SyntaxError
const v = a || b ?? c;

// Fine, and you have to say which you meant
const v1 = (a || b) ?? c;
const v2 = a || (b ?? c);

The language designers made this an error on purpose, because || and ?? have genuinely different notions of "empty" and a silent precedence rule would have hidden bugs. Take it as a prompt to re-read the line.

One more misconception: ?? does not check for an empty object, an empty array or a whitespace-only string. [] ?? 'none' returns the empty array. If your "missing" case is an empty array from an API, you need an explicit length check. And ?? does not reach inside objects, so { city: null } ?? { city: 'Pune' } gives you the first object with its null intact. Defaults for individual keys still need per-key handling, or destructuring defaults, which themselves only apply when the value is undefined, not null.

?. has three forms, not one

Optional chaining stops evaluation and yields undefined when the thing to its left is null or undefined, instead of throwing TypeError: Cannot read properties of undefined.

const order = { id: 'PD-1042', customer: { name: 'Arjun' } };

order.address.pin;       // TypeError, address is undefined
order.address?.pin;      // undefined, no throw

Most people know that form. There are two more that get used less and solve real problems.

Optional index access with ?.[], for arrays and computed keys:

order.items?.[0]?.sku;          // undefined if items is missing or empty
settings?.[currentUser.role];   // computed key, safe if settings is nullish

Optional call with ?.(), for callbacks and methods that may not exist:

function Modal({ onClose }) {
  // runs onClose only if it was passed
  return <button onClick={() => onClose?.()}>Close</button>;
}

api.legacyMethod?.(); // no crash if the SDK dropped it

Three limits are worth memorising. First, ?.() only guards against null and undefined, not against a value that exists but is not callable. const o = { fn: 42 }; o.fn?.() still throws TypeError: o.fn is not a function, which is correct behaviour and a genuinely useful signal.

Second, optional chaining does not protect against an undeclared variable. notDeclared?.value throws ReferenceError because the identifier itself does not exist. Use typeof notDeclared !== 'undefined' for that rare case.

Third, it cannot appear on the left of an assignment. obj?.prop = 1 is a SyntaxError. It does work with delete, though: delete user?.session is a safe no-op when user is nullish.

Combining ?. and ??, and the short-circuit surprise

The two operators are designed to be used together. ?. gets you undefined instead of a crash, and ?? turns that undefined into a sensible default.

const pin = order.address?.pin ?? '000000';
const itemCount = cart.items?.length ?? 0;
const theme = user?.prefs?.theme ?? 'light';

Note that cart.items?.length ?? 0 is better than cart.items?.length || 0 only in intent here, since both give 0, but the first says what you mean. Where it matters is user?.prefs?.fontScale ?? 1, because a deliberately chosen scale of 0 would be destroyed by ||.

The surprise is how far the short circuit reaches. When the chain short circuits, the entire rest of the expression is skipped, including function arguments that look like they should be evaluated:

let count = 0;
const bump = () => ++count;

const obj = null;
obj?.method(bump());

console.log(count); // 0, bump() never ran

That is usually what you want, but it means you must never rely on a side effect inside an optionally chained call. If bump() incremented a request counter or logged an analytics event, it silently stops happening on the null path.

What the short circuit does not do is make the rest of the chain safe forever. It only fires when the value at the optional link itself is nullish. A plain dot further along is still a plain dot, so a later property that can independently be null will still throw:

const a = { b: null };
a?.b.c;   // TypeError, b is null and .c is not optional
a?.b?.c;  // undefined

Put ?. at each link that can genuinely be missing, and use plain . where the property is guaranteed. That gives you a readable map of which parts of the shape you actually trust.

When ?. quietly hides a real bug

Optional chaining is the easiest operator in JavaScript to overuse. Once a developer gets burned by a TypeError in production, the instinct is to sprinkle ?. across the file until the error stops. The error stops. The bug does not.

// The API changed 'user' to 'account'. This never throws.
const label = `Welcome, ${res?.data?.user?.name}`;
// 'Welcome, undefined' rendered on every page

Worse than a visible undefined is one that gets written back. { city: undefined } disappears from JSON.stringify, so a PATCH request silently omits the field. Or the string "undefined" ends up saved as a customer name because it was built with a template literal. A TypeError in your error monitor on day one is far cheaper than a table full of garbage found in month three.

A workable rule: use ?. where the value is legitimately optional, and let it throw where the value is required.

  • Optional: a callback prop, a middle name, an address on a guest order, an SDK method that may not exist in older versions.
  • Required: the response body of an endpoint that promised to return it, a route parameter, an element you just queried and expect to exist.

For required data, validate once at the boundary instead of chaining forever inside the app:

const res = await fetch('/api/order/PD-1042');
if (!res.ok) throw new Error(`Order fetch failed: ${res.status}`);

const { order } = await res.json();
if (!order?.id) throw new Error('Malformed order response');

// from here on, order.id and order.customer.name are trusted

If you use TypeScript, turn on strictNullChecks. The compiler then tells you exactly which properties can be null or undefined, and using ?. on a value that is never nullish becomes a visible smell rather than harmless noise. Do keep in mind that TypeScript types describe what you declared, not what the server actually sent, so runtime validation of API responses is still your job.

Frequently Asked Questions

Is ?? always better than ||? Not always, but it is the better default for supplying fallbacks. Use ?? when only a missing value should be replaced, which is the case for numbers, text fields, booleans and anything a user can legitimately set to zero, empty or false. Use || deliberately when every falsy value really should be replaced, such as turning an empty trimmed string into 'Untitled'. If you cannot explain why || is correct on that line, use ??.
Why do I get a SyntaxError when I mix ?? with ||? The language forbids combining ?? with || or && at the same level without parentheses, because the two operators disagree about what counts as empty and a silent precedence rule would hide bugs. Write (a || b) ?? c or a || (b ?? c) to state your intent. This is a parse error, so the whole script or module fails to load, not just that expression.
Does optional chaining slow down my code? The cost is a nullish check per optional link, which is negligible compared with almost anything else on the page such as DOM work, network requests or rendering. Do not avoid ?. for performance reasons. Avoid it where the value should never be missing, because there the real cost is a silent undefined flowing through your app instead of a stack trace pointing at the actual problem.
Why does someObj?.method() still throw sometimes? Because ?. before a call only checks whether the thing to its left is null or undefined. If the property exists but holds a number, a string or an object, calling it throws TypeError: not a function. The form that guards the call itself is method?.(), with the question mark immediately before the parentheses. Also remember that ?. never protects an undeclared identifier, which throws ReferenceError instead.
Do destructuring defaults behave like ?? or like ||? Like neither exactly. A destructuring default applies only when the value is undefined, so const { city = 'Pune' } = user gives 'Pune' for a missing key but keeps null if the API explicitly sent null. That trips people up with JSON responses, where absent fields often arrive as null rather than being omitted. If null should also fall back, apply ?? after destructuring.