Complete JavaScript Course Module 14: Advanced: Advanced Language Features

Modern JavaScript Syntax: Optional Chaining, Nullish Coalescing and More

  • Beginner
  • Lesson 14.3
  • 5 min read
  • 63 of 76 in the course

In short

Modern JavaScript syntax like optional chaining ?., nullish coalescing ??, logical assignment, at(), structuredClone() and Object.groupBy() makes everyday code shorter and safer. Optional chaining (?.): read nested data safely Data from an API often has missing parts.

Modern JavaScript syntax like optional chaining ?., nullish coalescing ??, logical assignment, at(), structuredClone() and Object.groupBy() makes everyday code shorter and safer.

Optional chaining (?.): read nested data safely

Data from an API often has missing parts. If you try to read a property of undefined, JavaScript throws an error and your script stops. Optional chaining ?. checks for you: if the left side is null or undefined, the whole expression simply returns undefined instead of crashing.

const user = {
  name: 'Ana',
  address: null,
  getGreeting() { return `Hi, ${this.name}!`; },
};

// console.log(user.address.city);   // TypeError: Cannot read properties of null

console.log(user.address?.city);       // Output: undefined
console.log(user.settings?.theme);     // Output: undefined
console.log(user.getGreeting?.());     // Output: Hi, Ana!
console.log(user.sayBye?.());          // Output: undefined (method does not exist)

const orders = null;
console.log(orders?.[0]);              // Output: undefined

There are three forms: obj?.prop for properties, obj?.[key] for brackets and arrays, and fn?.() to call a function only if it exists.

Common mistake: Don't put ?. everywhere. If a value should always exist, a crash tells you about a real bug. Use ?. only where data can honestly be missing.

Nullish coalescing (??) vs the OR operator (||)

You often want a default value when something is missing. The old way was ||, but it has a trap: it treats 0, '' (empty string) and false as "missing" too.

The nullish coalescing operator ?? only uses the default when the left side is null or undefined ("nullish").

const settings = { volume: 0, nickname: '', darkMode: false };

console.log(settings.volume || 50);    // Output: 50   (wrong! 0 was a real choice)
console.log(settings.volume ?? 50);    // Output: 0    (correct)

console.log(settings.nickname ?? 'Guest');   // Output: ""  (empty, but set)
console.log(settings.language ?? 'en');      // Output: en  (missing, so default)

Use ?? when 0, '' or false are valid values. Use || only when you really want any "falsy" value replaced.

Logical assignment: ??=, ||= and &&=

These three operators combine a check and an assignment.

  • a ??= b sets a to b only if a is null or undefined.
  • a ||= b sets a to b only if a is falsy.
  • a &&= b sets a to b only if a is truthy.
const config = { retries: 0, title: '', token: 'abc123' };

config.retries ??= 3;              // retries is 0 (not nullish), stays 0
config.timeout ??= 5000;           // timeout was missing, now 5000
config.title ||= 'Untitled';       // '' is falsy, becomes 'Untitled'
config.token &&= config.token.toUpperCase();  // token exists, so transform it

console.log(config);
// Output: { retries: 0, title: "Untitled", token: "ABC123", timeout: 5000 }

??= is the one you will use most, for filling in default options.

More modern helpers: at(), numeric separators, structuredClone and friends

Numeric separators

Big numbers are hard to read. You can put underscores between digits; JavaScript ignores them.

const population = 8_100_000_000;
const price = 1_299.99;
console.log(population);  // Output: 8100000000

Array.prototype.at()

at() reads an item by position, and negative numbers count from the end.

const steps = ['cart', 'shipping', 'payment', 'done'];
console.log(steps.at(0));    // Output: cart
console.log(steps.at(-1));   // Output: done  (last item)
console.log(steps.at(-2));   // Output: payment

structuredClone()

structuredClone() makes a deep copy: a full copy where nested objects are copied too, not shared. The spread { ...obj } only copies the top level.

const original = { name: 'Ana', skills: ['js'], joined: new Date('2024-01-15') };

const shallow = { ...original };
const deep = structuredClone(original);

deep.skills.push('css');
console.log(original.skills);   // Output: ["js"]  (unchanged)

shallow.skills.push('html');
console.log(original.skills);   // Output: ["js", "html"]  (shared array changed!)

console.log(deep.joined instanceof Date);  // Output: true

structuredClone() keeps Dates, Maps and Sets correct, unlike the old JSON.parse(JSON.stringify(obj)) trick. It cannot copy functions or DOM elements; it throws an error if you try.

Object.hasOwn()

Object.hasOwn(obj, 'key') checks if an object has its own property (not one inherited from its prototype).

const product = { name: 'Lamp', price: 0 };
console.log(Object.hasOwn(product, 'price'));     // Output: true
console.log(Object.hasOwn(product, 'toString'));  // Output: false (inherited)

Object.groupBy(): group an array by a key

Object.groupBy() sorts array items into groups, using a function that returns each item's group name.

const products = [
  { name: 'T-shirt', category: 'clothes', price: 15 },
  { name: 'Mug', category: 'kitchen', price: 8 },
  { name: 'Jeans', category: 'clothes', price: 40 },
  { name: 'Pan', category: 'kitchen', price: 25 },
];

const byCategory = Object.groupBy(products, (item) => item.category);
console.log(byCategory.clothes.map((p) => p.name));
// Output: ["T-shirt", "Jeans"]

const byPrice = Object.groupBy(products, (item) => (item.price < 20 ? 'cheap' : 'pricey'));
console.log(Object.keys(byPrice));
// Output: ["cheap", "pricey"]

The function runs once per item, and items with the same answer go into the same array. Map.groupBy() does the same but returns a Map.

Note: Object.groupBy() works in all major browsers since 2024. For older browsers, you would use reduce() to build the groups by hand.

Modern vs old way: a quick comparison table

TaskOld wayModern way
Read a nested property safelyuser && user.address && user.address.cityuser?.address?.city
Default only for null/undefinedx !== null && x !== undefined ? x : 10x ?? 10
Set a default optionif (opts.size == null) opts.size = 'md';opts.size ??= 'md';
Get the last array itemarr[arr.length - 1]arr.at(-1)
Deep copy an objectJSON.parse(JSON.stringify(obj))structuredClone(obj)
Check an own propertyobj.hasOwnProperty('key')Object.hasOwn(obj, 'key')
Group itemsA long reduce()Object.groupBy(arr, fn)
Write a big number10000001_000_000

Practice exercise

You get this API response: const data = { user: { name: 'Ben', stats: { posts: 0 } }, items: [3, 7, 9] };. Using modern syntax, log the user's posts count with a default of 'none' only when missing (it should print 0), log data.user.profile.bio safely, print the last item in items, and make a deep copy of data that you can change without affecting the original.

Hint: you need ?., ??, at(-1) and structuredClone().

Key takeaways

  • ?. stops at null or undefined and returns undefined instead of crashing.
  • ?? gives a default only for null or undefined, so 0 and '' are kept.
  • ??=, ||= and &&= combine a check and an assignment in one short line.
  • at(-1) reads from the end, and structuredClone() makes a true deep copy.
  • Object.groupBy() and Object.hasOwn() replace longer, older patterns.