A practical, senior-dev guide to ?.
, ?.()
, and ?.[]
—how to write safer JavaScript/TypeScript without the pyramid of &&
, plus real-world patterns, React usage, and gotchas.

?.
, ?.()
, and ?.[]
—how to write safer JavaScript/TypeScript without the pyramid of &&
, plus real-world patterns, React usage, and gotchas.Introduction
We’ve all written this:
const email = user && user.profile && user.profile.contact && user.profile.contact.email;
It works… until you need to insert one more layer, then another. Readability tanks, bugs sneak in, and your diffs become noise.
Optional chaining changes the game:
const email = user?.profile?.contact?.email;
Cleaner, safer, and designed for today’s API-shaped data. In this post we’ll cover every useful pattern: the three forms (?.
, ?.()
, ?.[]
), when to combine with nullish coalescing (??
), performance and transpilation notes, TypeScript specifics, React usage, refactoring strategies, and the gotchas that bite even experienced devs.
1) What Optional Chaining Actually Does (and Doesn’t)
Definition: Optional chaining stops property access/calls/indexing if the left side is null
or undefined
and returns undefined
instead of throwing.
obj?.prop
→ ifobj == null
⇒undefined
, elseobj.prop
.obj?.[expr]
→ same, but dynamic key.obj?.method?.()
→ first checksobj?.method
; if it’snull/undefined
⇒ returnsundefined
without calling; else calls it.
Important: It only guards against nullish (null
/undefined
)—not other falsy values (0
, ''
, false
, NaN
). That’s a feature.
2) Replacing the Pyramid of &&
Before:
const city = data && data.user && data.user.address && data.user.address.city;
After:
const city = data?.user?.address?.city;
Why better:
- Fewer cognitive branches.
- Fewer typos and reorder bugs.
- Diffs are smaller when structure changes.
3) The Three Flavors You’ll Use Daily
A) Property access: ?.
const theme = settings?.ui?.theme ?? 'light';
B) Element access (dynamic keys): ?.[]
const key = formState?.currentFieldKey;
const value = formState?.values?.[key];
C) Optional call: ?.()
listener?.(payload); // Calls only if listener is a function
obj?.method?.(arg1, arg2); // Safe method call if obj and method exist
Tip: ?.()
is perfect for callbacks and optional hooks.
4) Pairing with Nullish Coalescing ??
(Not ||
)
??
uses nullish logic: only falls back on null
or undefined
.
const port = config?.server?.port ?? 3000; // 0 stays 0, '' stays ''
With ||
, falsy values get replaced unintentionally:
const port = config?.server?.port || 3000; // 0 becomes 3000 (bug!)
Rule of thumb: use ??
with optional chaining 90% of the time.
5) Practical Patterns You’ll Reuse
A) Safe read with fallback
const name = profile?.user?.displayName ?? '(anonymous)';
B) Optional callback invocation
function onSubmit(data, callbacks) {
callbacks?.before?.(data);
// …do work…
callbacks?.after?.(data);
}
C) Optional promise-like API
await hooks?.preFetch?.();
const res = await fetch(url);
await hooks?.postFetch?.(res);
D) Optional array access
const firstTag = post?.tags?.[0] ?? 'uncategorized';
E) Optional map/dict lookup
const label = dict?.[locale]?.[key] ?? key;
F) Optional DOM APIs (browser)
const value = document.querySelector('#email')?.value ?? '';
G) Optional chaining + destructuring defaults
const { x = 0, y = 0 } = point ?? {};
When you need multiple fields with defaults, destructure from
obj ?? {}
to avoid repeating?.
.
6) React Patterns
A) Rendering optional props
<UserCard
name={user?.name ?? 'Guest'}
avatarUrl={user?.profile?.avatar}
/>
B) Optional event handlers
<button onClick={handlers?.onClick}>Click me</button>
C) Guarded lists
<ul>
{(data?.items ?? []).map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
D) Suspense-like hooks
useEffect(() => {
return cleanup?.(); // cleanup is optional
}, []);
7) TypeScript: Tips You’ll Actually Use
A) Works best with strictNullChecks
Optional chaining shines when the compiler understands null | undefined
.
interface User { profile?: { email?: string } }
const email = user.profile?.email ?? 'n/a';
B) Optional call inference
type Listener = ((x: number) => void) | undefined;
declare const onChange: Listener;
onChange?.(42); // Type-safe
C) Optional element access
declare const map: Record<string, number> | undefined;
const n = map?.[key] ?? 0;
D) Narrowing still matters
if (user?.profile) {
// user.profile: { ... } (not undefined here)
}
E) Avoid double-guarding
Don’t do user && user?.profile?.name
; the ?.
already guards.
8) Migration: Refactor Old &&
Chains
Before
const email =
user && user.profile && user.profile.contact && user.profile.contact.email || 'n/a';
After
const email = user?.profile?.contact?.email ?? 'n/a';
How to refactor safely
- Replace each
&& next
with?.next
. - Replace trailing
|| fallback
with?? fallback
(to preserve 0/”/false). - Run tests; watch for falsy-but-valid values.
9) Real-World Scenarios
A) API responses with optional fields
const nextCursor = response?.meta?.pagination?.next_cursor ?? null;
B) Feature flags
if (flags?.newCheckout?.enabled) {
// …
}
C) i18n dictionaries
const t = (k) => dict?.[locale]?.[k] ?? k;
D) Safe telemetry hooks
telemetry?.track?.('user_login', { id });
E) Unified access across differing shapes
const id = (row?.id ?? row?.ID ?? row?.Id) ?? null;
10) Edge Cases & Gotchas (Read This Twice)
1) Only stops on null
/undefined
const len = maybeArray?.length ?? 0; // ok
// But falsey values like '' or 0 do NOT short-circuit
2) Short-circuiting halts evaluation
obj?.expensiveCall(); // Not invoked if obj is nullish (good)
Be mindful: arguments aren’t evaluated either if the call is skipped.
3) Precedence + parentheses
// ?. has lower precedence than function call ()
obj?.fn?.()(x); // ok
(obj?.fn)?.(x); // also ok
// Access then nullish-coalesce
const n = obj?.value ?? 10; // ok
4) Don’t chain after a definite non-optional
If you know a segment exists, no need for ?.
everywhere—use it only where needed.
5) Mixing with delete
Yes, you can:
delete obj?.temp; // Safe no-op if obj is nullish
6) Using with in
and typeof
in
doesn’t combine with ?.
, but you can guard:
const has = obj?.prop !== undefined; // basic presence check
7) Optional chaining doesn’t clone
You’re reading references; if you mutate later, you’re still mutating the same object.
8) Optional chains on the left side of assignment are invalid
user?.profile = {} // ❌ Syntax error
Assign to a definite reference or use a guard.
9) Beware accidental swallowing of errors
If you expect something to exist and want an error if it doesn’t, don’t use ?.
there. Let it throw.
11) Performance Notes (Pragmatic)
- Microcost is negligible vs. readability wins.
- Optional chaining can help avoid exceptions, which are expensive.
- In hot loops, consider pre-narrowing:
const p = user?.profile; if (!p) return; // use p repeatedly
12) Transpilation & Runtime Support
- Modern browsers/Node: Supported natively.
- Older environments: Use Babel/TypeScript to transpile. The compiler rewrites
?.
into equivalent guards; bundle size impact is small. - No polyfill needed (it’s syntax, not a runtime API).
Babel/TS example config
- TypeScript: target
ES2020
+ to keep native?.
; or lower target to transpile. - Babel:
@babel/preset-env
handles it based on targets.
13) Test-Driven Confidence (Quick Cases)
Create focused tests to lock behavior:
import { expect, it } from 'vitest';
it('keeps falsy values with ??', () => {
const cfg = { port: 0 };
const port = cfg?.port ?? 3000;
expect(port).toBe(0);
});
it('skips when left is nullish', () => {
const res = (null as any)?.x?.y;
expect(res).toBeUndefined();
});
it('optional call only invokes when function exists', () => {
let count = 0;
const cb = undefined as undefined | (() => void);
cb?.();
expect(count).toBe(0);
});
14) Handy Recipes (Copy–Paste)
A) Safe deep read with typed fallback
export const get = <T>(fn: () => T, fallback: T): T => {
try { const v = fn(); return v ?? fallback; } catch { return fallback; }
};
// usage
const email = get(() => user!.profile!.contact!.email, 'n/a'); // when you prefer try/catch-free callers
B) Hydrate object with defaults
const cfg = {
host: input?.host ?? 'localhost',
port: input?.port ?? 3000,
ssl: input?.ssl ?? false,
};
C) Optional chaining in reducers
function reducer(state, action) {
switch (action.type) {
case 'patch':
return { ...state, ...action.payload?.user?.profile };
default:
return state;
}
}
D) Safe access + transform
const domain = user?.email?.split('@')?.[1] ?? 'example.com';
E) Optional date parsing
const created = data?.timestamps?.createdAt ? new Date(data.timestamps.createdAt) : null;
15) Linting & Team Conventions
- Enable rules that encourage
?.
over&&
ladders (eslint-plugin-unicorn
has suggestions; many teams use custom rules). - Prefer
??
over||
next to?.
. - Code review heuristic: If you wrote 2+
&&
for null checks, reach for?.
.
16) When Not to Use Optional Chaining
- Invariant fields where absence is a bug — let it throw or assert:
const cfg = loadConfig();
if (!cfg) throw new Error('config required');
doWork(cfg.server!.host); // or a runtime assert
- Security-sensitive paths where silently returning
undefined
could hide a misconfiguration. - Performance-critical code where direct access with prior validation is faster and clearer.
Conclusion
Optional chaining is one of those features that instantly improves readability and safety:
- Use
?.
,?.[]
, and?.()
to avoid brittle&&
chains. - Pair with
??
to preserve valid falsy values. - Apply it in React props, optional hooks, API parsing, and dynamic lookups.
- Respect the gotchas: only nullish short-circuits, precedence, and not masking real errors.
Pro Tip: Treat optional chaining as a map of your data contract. Use it where the structure is genuinely optional; assert where it isn’t. Your future self (and your teammates) will thank you.
Call to Action (CTA)
What’s the ugliest &&
chain you’ve replaced with optional chaining?
Drop a snippet in the comments—and share this with a teammate who still writes user && user.profile && ...
.
Leave a Reply