Optional Chaining and Safe Access

Mental model: ?. means "if this is null or undefined, stop and give me undefined", nothing more.

Level: beginner · about 9 minutes

const user = { name: 'Ada' };

try {
  console.log(user.address.city);   // TypeError: Cannot read properties of undefined
} catch (err) {
  console.log(err.constructor.name); // 'TypeError'
}

console.log(user.address?.city);    // undefined, no throw

The error on the left is the most common runtime error in JavaScript.

Optional chaining (?.) checks the value on its left. If that value is null or undefined, the whole expression short circuits to undefined. Otherwise it carries on as a normal access.

Three forms

const api = { data: { items: [{ id: 1 }] } };

console.log(api.data?.items?.[0]?.id);   // 1        property and index
console.log(api.missing?.items?.[0]?.id);// undefined
console.log(api.onError?.('boom'));      // undefined, the call is skipped
console.log(api.data?.items?.length);    // 1
`obj?.prop`
property access
`obj?.[expr]`
computed or index access
`fn?.(args)`
call only if fn is not nullish
`obj?.method?.()`
the method might be missing as well as the object

Pairing with ??

const settings = { display: { fontSize: 0 } };

console.log(settings.display?.fontSize ?? 16);   // 0,  a real value wins
console.log(settings.display?.fontSize || 16);   // 16, because 0 is falsy
console.log(settings.audio?.volume ?? 50);       // 50, genuinely missing

|| falls back on any falsy value

count || 10
// fires for 0, '', false, NaN

?? falls back only on nullish

count ?? 10
// fires for null and undefined only

Use ?? for defaults on values where 0, "" or false are meaningful. Use || only when every falsy value really should be replaced.

A small deep get

function deepGet(source, path, fallback) {
  let current = source;
  for (const key of String(path).split('.')) {
    if (current === null || current === undefined) return fallback;
    current = current[key];
  }
  return current === undefined ? fallback : current;
}

const data = { user: { roles: ['admin'] } };
console.log(deepGet(data, 'user.roles.0'));          // 'admin'
console.log(deepGet(data, 'user.team.name', 'none')); // 'none'

Optional chaining reads a path you know at author time. A helper like this reads a path built at runtime, from configuration or a form field name. You need both, but reach for the syntax first.

function deepSet(target, path, value) {
  const keys = String(path).split('.');
  const last = keys.pop();
  let current = target;
  for (const key of keys) {
    if (typeof current[key] !== 'object' || current[key] === null) current[key] = {};
    current = current[key];
  }
  current[last] = value;
  return target;
}

console.log(deepSet({}, 'a.b.c', 1)); // { a: { b: { c: 1 } } }

Writing needs its own helper: ?. cannot appear on the left of an assignment.

When ?. hides the bug instead of fixing it

Hiding the problem

// The response shape changed and
// nobody noticed for three weeks.
const total = res?.data?.cart?.total;
render(total);  // undefined, silently

Handling the problem

const total = res?.data?.cart?.total;
if (total === undefined) {
  report('cart total missing', res);
  return renderEmptyCart();
}
render(total);

Optional chaining is right when absence is a normal, expected state. It is wrong when absence means something upstream is broken, because it converts a loud crash into a quiet undefined that spreads.

const o = { a: { b: null } };
console.log(o.a?.b?.c, o.x?.y, o.a.b?.c ?? 'dflt');

o.a?.b is null, so the next ?.c short circuits to undefined. o.x?.y short circuits at o.x. The third expression is undefined ?? "dflt", which is "dflt". Nothing throws because every nullish step was guarded.

Try it yourself

Read a shape that keeps changing

const response = {
  data: { profile: { name: 'Ada', address: null }, items: [] },
  onDone: null,
};

console.log(response.data?.profile?.name);            // 'Ada'
console.log(response.data?.profile?.address?.city);   // undefined
console.log(response.data?.items?.[0]?.id ?? 'empty');// 'empty'
console.log(response.onDone?.('finished'));           // undefined, not called
console.log(response.data?.items?.length ?? 0);       // 0

Delete profile from response.data and see which lines still work. Then remove one ?. and watch it throw.

Exercises

Write deepGet

Write deepGet(source, path, fallback) where path is a dotted string such as "user.address.city". Return the value at that path, or fallback when any step is missing or undefined. Array indexes work as numeric segments: "items.0.name". A stored null or 0 is a real value and must be returned as is.

Check yourself

What does this log?
a TypeError — o.a is not nullish, so ?.b proceeds and gives undefined. The plain .c that follows is unguarded, so it throws. Optional chaining only protects the link it is written on.
Which expression returns 0 when size is 0?
opts?.size ?? 10 — ?? only falls back for null and undefined, so a real 0 is kept. Every || version replaces 0 with 10, because 0 is falsy.
When is optional chaining the wrong tool?
When the value being missing means something upstream is broken and should be reported — It turns a loud failure into a quiet undefined. That is ideal for genuinely optional data and harmful when the absence is a bug, because the undefined then travels somewhere else before anything complains.

Common mistakes

  • Writing one ?. and assuming the rest of the chain is protected.
  • Using || for defaults, so 0, "" and false get silently replaced.
  • Sprinkling ?. across a whole file to stop crashes, which hides shape changes instead of fixing them.

Takeaways

  • ?. short circuits to undefined when the value on its left is null or undefined.
  • It comes in three forms: ?.prop, ?.[expr] and ?.(args).
  • Pair it with ?? so meaningful falsy values survive.
  • Use it where absence is expected, and report the failure where absence is a bug.