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 ??= bsetsatobonly ifaisnullorundefined.a ||= bsetsatobonly ifais falsy.a &&= bsetsatobonly ifais 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 usereduce()to build the groups by hand.
Modern vs old way: a quick comparison table
| Task | Old way | Modern way |
|---|---|---|
| Read a nested property safely | user && user.address && user.address.city | user?.address?.city |
| Default only for null/undefined | x !== null && x !== undefined ? x : 10 | x ?? 10 |
| Set a default option | if (opts.size == null) opts.size = 'md'; | opts.size ??= 'md'; |
| Get the last array item | arr[arr.length - 1] | arr.at(-1) |
| Deep copy an object | JSON.parse(JSON.stringify(obj)) | structuredClone(obj) |
| Check an own property | obj.hasOwnProperty('key') | Object.hasOwn(obj, 'key') |
| Group items | A long reduce() | Object.groupBy(arr, fn) |
| Write a big number | 1000000 | 1_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 atnullorundefinedand returnsundefinedinstead of crashing.??gives a default only fornullorundefined, so0and''are kept.??=,||=and&&=combine a check and an assignment in one short line.at(-1)reads from the end, andstructuredClone()makes a true deep copy.Object.groupBy()andObject.hasOwn()replace longer, older patterns.