Shorthand syntax and what it expands to
- Expand
?.,?.[]and?.()into explicit checks, and predict where a chain short-circuits and where it still throws - Choose
??or||(and??=,||=,&&=) by asking whether0,""orfalseis a real value - Predict destructuring results: defaults apply only to
undefined, renames, rest, nested patterns, andnullthrowing - Write object literals with computed keys and spread, and predict which value wins when spread keys overlap — including a key present as
undefined
Read
?. means "stop here if this is null or undefined"
Flaga?.b reads b from a — unless a is null or undefined, in which case the whole
expression is undefined and nothing after it runs. It is shorthand for a check you would
otherwise write by hand:
const city = user?.address?.city;
// expands to (roughly):
const sameCity =
user === null || user === undefined ? undefined
: user.address === null || user.address === undefined ? undefined
: user.address.city;
There are three forms, one for each way of reaching into a value:
| Shorthand | Guards | Instead of |
|---|---|---|
user?.name | user | user.name |
rows?.[i] | rows | rows[i] |
onChange?.(value) | onChange | onChange(value) |
The third one is the one people miss in review: props.onChange?.(value) calls onChange only
if it was passed, so an optional callback prop doesn't need an if.
Two things ?. does not do:
- It checks only for
nullandundefined. An empty string,0orfalseon the left is a real value, and the access goes ahead. - It guards only the value directly to its left.
user?.address.cityprotects you from a missinguser, but throws ifuserexists andaddressdoesn't.
It always produces undefined when it stops — never null, even if the value it stopped at was
null.
Destructuring, line by line
FlagA component's props, an API response and a function's options object are all unpacked the same way. One line that does five things:
function OrderRow({ order, currency = "USD", onSelect, ...rest }: OrderRowProps) { … }
const { data: orders = [], error } = await fetchOrders();
const { id: orderId, customer: { name } } = order;
const [first, , third = "n/a"] = lines;
Read each piece as the property access it replaces:
{ order }—const order = props.order.currency = "USD"—const currency = props.currency === undefined ? "USD" : props.currency. The default applies only when the value isundefined— missing, or explicitlyundefined. Anullis a value, and you getnull....rest— a new object holding every own enumerable property that wasn't named (className,aria-*, …), ready to spread onto a DOM element. It must come last.data: orders = []— rename and default: readdata, call itorders, use[]if it'sundefined. The colon in a pattern means "rename", not "type" — TypeScript annotations go after the whole pattern (}: OrderRowProps).customer: { name }— nested: readorder.customer, then itsname. This throws aTypeErroriforder.customerisundefined; there is no?.inside a pattern. Give the middle level a default (customer: { name } = {}) if it can be missing.[first, , third = "n/a"]— arrays destructure by position; an empty slot skips one.
The null trap, side by side
APIs and databases often send null for "no value", and JSON.parse keeps it. Destructuring
defaults won't catch it:
const response = JSON.parse('{"pageSize": null}');
const { pageSize = 20 } = response;
pageSize; // null — the default was skipped
const size = response.pageSize ?? 20;
size; // 20 — ?? treats null and undefined alike
TypeScript models this exactly: for pageSize?: number | null, the destructured pageSize is
typed number | null, so the compiler tells you the default didn't cover everything.
Function parameters follow the same rule. function paginate({ page = 1 } = {}) works when called
with nothing or undefined, but paginate(null) throws: the = {} default only replaces
undefined, and destructuring null is a TypeError.
Where a ?. chain stops — and where it still throws
FlagThe whole chain short-circuits
When ?. meets null or undefined, it skips everything to its right in the same chain —
property reads, index expressions, calls, their side effects:
let i = 0;
const order = null;
const sku = order?.items[i++].sku;
sku; // undefined
i; // 0 — items[i++] was never evaluated
There is no ?. after items, yet nothing threw: once order?. stopped, .items[i++].sku
didn't run. The chain ends at a parenthesis, though: (order?.items).length evaluates
(undefined).length and throws a TypeError.
What each form still lets through
a?.b.cguardsaonly. Ifaexists anda.bisundefined,.cthrows.fn?.()checks thatfnisn'tnull/undefined— not that it is a function. Withprops.onChangeset to a string,props.onChange?.()throwsprops.onChange is not a function. And it doesn't guardpropsitself: that needsprops?.onChange?.().- No optional assignment.
user?.name = "Ada"is aSyntaxError. Assign inside anif.
?? refuses to mix
a || b ?? c and a && b ?? c are SyntaxErrors — the language won't guess the precedence you
meant, and the whole module fails to load. Add parentheses: (a || b) ?? c.
?. vs && chains
Older code writes user && user.profile && user.profile.name. It is not the same thing: &&
stops at any falsy value and returns it, while ?. stops only at null/undefined and
returns undefined. With user = null, the && chain gives null and user?.profile?.name
gives undefined. And a falsy value that isn't nullish stops && but not ?.: with price = 0,
price && price.toFixed(2) is 0, while price?.toFixed(2) is "0.00". That difference shows up
when the result is rendered or compared with === undefined.
TypeScript's ! is not a runtime check
props.onSelect!(item) looks like a cousin of ?., but the ! (non-null assertion) is erased
when TypeScript compiles — the emitted code is props.onSelect(item). It tells the compiler "trust
me, this isn't null", and if you're wrong it crashes exactly as the plain call would. ?. checks at
runtime; ! checks nothing.
Quiz
What does user?.profile?.avatarUrl evaluate to?
A component calls props.onChange?.(value). What does the ?. guard against?
In setForm({ ...form, [e.target.name]: e.target.value }), what do the square brackets do?
const volume = saved.volume || 0.8; — users who muted the player (volume: 0) find it un-muted after a reload. Which line fixes it?
settings.theme ??= detectTheme(); — when does detectTheme() run?
Given function paginate({ page = 1 } = {}) { return page; }, which call throws?
After adding const label = user.nickname || user.name ?? "Anonymous"; the whole module fails to load. Why?
A reviewer suggests replacing props.onSelect?.(item) with props.onSelect!(item) "to make it stricter". What changes when onSelect isn't passed?
Fill in the blank
Call the optional onSelect callback only if the parent passed one.
props.onSelect(item);
Default the volume to 0.8, but keep a saved volume of 0 (muted).
const volume = saved.volume 0.8;
Read id as userId, default role to "viewer", and collect every other field in profile.
const { id: , role = , ... } = user;
One change handler for every input in the form: copy form, then set the field named by the input.
setForm({ ...form, : e.target.value });
Compute the value for key only if the cache has nothing (null/undefined) stored for it.
cache[key] compute(key);
Predict the output
let i = 0; const order = null; const sku = order?.items[i++].sku; console.log(sku, i);
const s = { volume: 0, nickname: "", muted: false };
console.log(s.volume || 50, s.volume ?? 50);
console.log(s.muted || true, s.muted ?? true);const res = JSON.parse('{"pageSize": null}');
const { pageSize = 20, sort = "newest" } = res;
console.log(pageSize, sort);const defaults = { theme: "light", fontSize: 14 };
const saved = { fontSize: 16, theme: undefined }; // a cleared form field
console.log({ ...defaults, ...saved });Challenge
Fill in the player's defaults without losing 0, "" or false
FlagresolveOptions turns a user's saved player settings into a complete set, filling in
DEFAULTS (in types.ts) for anything not set. Three people wrote it over time, each with a
different shorthand, and each shorthand has a hole:
||falls back on every falsy value, and some of these settings are legitimately0,""orfalse.- A destructuring default applies only to
undefined. The API sendsnullfor "not set". { ...DEFAULTS, ...saved }copies a key that is present with the valueundefined— which is what a cleared form field writes — straight over the default.
Some lines also tell the compiler to stop checking: the !s and an as string. None of them
changes what runs. TypeScript erases them all.
The preview resolves five real users' settings. Only one of them comes out right.
Your task
Edit options.ts so the tests pass:
- Missing or
nullretrysettings fall back to 3 attempts and 500 ms instead of throwing volume: 0stays0label: ""stays empty, andlabel: nullbecomes"Untitled"- A field present as
undefinedgets its default, andautoplay: falsestaysfalse signalReadycallsonReadywhen it's set, and does nothing when it isn't