JavaScript & TypeScript Fundamentals
JavaScript & TypeScript Fundamentals

Shorthand syntax and what it expands to

Full · ~30 min
After this unit you can
  • Expand ?., ?.[] and ?.() into explicit checks, and predict where a chain short-circuits and where it still throws
  • Choose ?? or || (and ??=, ||=, &&=) by asking whether 0, "" or false is a real value
  • Predict destructuring results: defaults apply only to undefined, renames, rest, nested patterns, and null throwing
  • Write object literals with computed keys and spread, and predict which value wins when spread keys overlap — including a key present as undefined

Read

Simple
1 / 2

?. means "stop here if this is null or undefined"

Flag

a?.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:

ShorthandGuardsInstead of
user?.nameuseruser.name
rows?.[i]rowsrows[i]
onChange?.(value)onChangeonChange(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 null and undefined. An empty string, 0 or false on the left is a real value, and the access goes ahead.
  • It guards only the value directly to its left. user?.address.city protects you from a missing user, but throws if user exists and address doesn't.

It always produces undefined when it stops — never null, even if the value it stopped at was null.

Medium
1 / 2

Destructuring, line by line

Flag

A 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 is undefined — missing, or explicitly undefined. A null is a value, and you get null.
  • ...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: read data, call it orders, use [] if it's undefined. The colon in a pattern means "rename", not "type" — TypeScript annotations go after the whole pattern (}: OrderRowProps).
  • customer: { name } — nested: read order.customer, then its name. This throws a TypeError if order.customer is undefined; 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.

Complex
1 / 2

Where a ?. chain stops — and where it still throws

Flag

The 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.c guards a only. If a exists and a.b is undefined, .c throws.
  • fn?.() checks that fn isn't null/undefined — not that it is a function. With props.onChange set to a string, props.onChange?.() throws props.onChange is not a function. And it doesn't guard props itself: that needs props?.onChange?.().
  • No optional assignment. user?.name = "Ada" is a SyntaxError. Assign inside an if.

?? 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?

Flag

A component calls props.onChange?.(value). What does the ?. guard against?

Flag

In setForm({ ...form, [e.target.name]: e.target.value }), what do the square brackets do?

Flag

const volume = saved.volume || 0.8; — users who muted the player (volume: 0) find it un-muted after a reload. Which line fixes it?

Flag

settings.theme ??= detectTheme(); — when does detectTheme() run?

Flag

Given function paginate({ page = 1 } = {}) { return page; }, which call throws?

Flag

After adding const label = user.nickname || user.name ?? "Anonymous"; the whole module fails to load. Why?

Flag

A reviewer suggests replacing props.onSelect?.(item) with props.onSelect!(item) "to make it stricter". What changes when onSelect isn't passed?

Flag

Fill in the blank

Call the optional onSelect callback only if the parent passed one.

Flag
props.onSelect(item);

Default the volume to 0.8, but keep a saved volume of 0 (muted).

Flag
const volume = saved.volume  0.8;

Read id as userId, default role to "viewer", and collect every other field in profile.

Flag
const { id: , role = , ... } = user;

One change handler for every input in the form: copy form, then set the field named by the input.

Flag
setForm({ ...form, : e.target.value });

Compute the value for key only if the cache has nothing (null/undefined) stored for it.

Flag
cache[key]  compute(key);

Predict the output

let i = 0;
const order = null;
const sku = order?.items[i++].sku;
console.log(sku, i);
Flag
const s = { volume: 0, nickname: "", muted: false };
console.log(s.volume || 50, s.volume ?? 50);
console.log(s.muted || true, s.muted ?? true);
Flag
const res = JSON.parse('{"pageSize": null}');
const { pageSize = 20, sort = "newest" } = res;
console.log(pageSize, sort);
Flag
const defaults = { theme: "light", fontSize: 14 };
const saved = { fontSize: 16, theme: undefined }; // a cleared form field
console.log({ ...defaults, ...saved });
Flag

Challenge

Fill in the player's defaults without losing 0, "" or false

Flag

resolveOptions 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 legitimately 0, "" or false.
  • A destructuring default applies only to undefined. The API sends null for "not set".
  • { ...DEFAULTS, ...saved } copies a key that is present with the value undefined — 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 null retry settings fall back to 3 attempts and 500 ms instead of throwing
  • volume: 0 stays 0
  • label: "" stays empty, and label: null becomes "Untitled"
  • A field present as undefined gets its default, and autoplay: false stays false
  • signalReady calls onReady when it's set, and does nothing when it isn't

Loading the in-browser editor…

Further reading