I've watched the JavaScript ecosystem evolve significantly over the past few years, and the language itself has introduced genuinely useful features that make production code cleaner and safer. Modern JavaScript features worth using aren't just syntactic sugar—they solve real problems we faced before, reducing boilerplate and catching bugs earlier. In this article, I'll walk you through the ones I actually reach for in my projects, explain why they matter, and show you how to use them effectively.
Optional chaining and nullish coalescing — Safe access and real defaults
Before optional chaining, checking nested properties was verbose and error-prone. If you wanted to access user.profile.address.street, you'd either do multiple checks or use libraries like Lodash's get function. Optional chaining with the ?. operator lets you safely access nested properties and method calls, short-circuiting to undefined if any step fails.
Here's a practical scenario from a CRM system I built. When displaying customer details, the data structure isn't always guaranteed to be complete:
// Before optional chaining
const customerName = user && user.profile && user.profile.customer && user.profile.customer.name;
// With optional chaining
const customerName = user?.profile?.customer?.name;
// Works with methods too
const emailSent = await customer?.sendNotification?.();
// And with bracket notation
const phoneNumber = customer?.contactInfo?.[0]?.phone;Nullish coalescing (??) is its natural partner. Unlike the logical OR operator ||, which treats any falsy value as a reason to use the default, nullish coalescing only triggers on null or undefined. This matters when you have legitimate falsy values like 0, empty strings, or false.
// Dangerous: 0 is falsy, so this defaults to 10
const retryCount = config.retries || 10; // Returns 10 if config.retries is 0
// Correct: 0 is a valid value, use ?? instead
const retryCount = config.retries ?? 10; // Returns 0 if config.retries is 0
// In my e-commerce platform, this mattered with pricing
const discount = product.discount ?? 0; // 0% discount is valid, null is notIn production systems, this combination eliminates entire categories of defensive coding. I stopped reaching for Lodash get in most cases once these landed in all supported browsers and runtimes.
Top-level await — Await in ES modules without wrapping functions
Top-level await lets you use await directly in module scope, not just inside async functions. This seems small until you're initializing modules—suddenly your code reads linearly without nested async wrapper functions.
In a trading platform I helped build, we needed to connect to multiple services on startup. Before top-level await, you'd need wrapper functions:
// Before: async wrapper pattern
async function initializeServices() {
const dbConnection = await connectDatabase();
const redisClient = await initializeRedis();
const kafkaProducer = await setupKafka();
return { dbConnection, redisClient, kafkaProducer };
}
const services = await initializeServices();
export { services };With top-level await, it reads more naturally:
// After: top-level await
const dbConnection = await connectDatabase();
const redisClient = await initializeRedis();
const kafkaProducer = await setupKafka();
export { dbConnection, redisClient, kafkaProducer };Note that this only works in ES modules (files with type: "module" in Node.js or in the browser with proper module scripts). The module loader waits for top-level awaits to complete before considering it fully loaded, so consumers automatically get initialized dependencies.
The real benefit surfaces in complex initialization chains. You can conditionally await resources, handle errors clearly, and the flow matches your mental model. I've used this in all my recent SaaS backends for startup sequences.
Set methods — union, intersection and difference are now built in
For years, working with Sets meant reaching for helper libraries when you needed set operations. ES2024 finally added union(), intersection(), difference(), symmetricDifference(), and isSubsetOf() methods directly to Set.
In a ride-hailing application I worked on, we frequently compared user pools—matching drivers with passengers, excluding blocked users, finding users in multiple regions. Here's how this plays out:
const activeDrivers = new Set(['driver1', 'driver2', 'driver3', 'driver4']);
const blockedUsers = new Set(['driver3', 'driver5']);
const driversNearPickup = new Set(['driver1', 'driver3', 'driver6']);
// Union: all unique drivers
const allCandidates = activeDrivers.union(driversNearPickup);
// Set(5) { 'driver1', 'driver2', 'driver3', 'driver4', 'driver6' }
// Difference: drivers without blocks
const eligibleDrivers = activeDrivers.difference(blockedUsers);
// Set(3) { 'driver1', 'driver2', 'driver4' }
// Intersection: drivers that are both active AND near pickup
const bestMatches = activeDrivers.intersection(driversNearPickup);
// Set(2) { 'driver1', 'driver3' }
// Symmetric difference: drivers in one pool but not both
const someButNotBoth = activeDrivers.symmetricDifference(driversNearPickup);
// Set(3) { 'driver2', 'driver4', 'driver6' }
// Subset check: are all blocked users in active drivers?
const allBlockedAreActive = blockedUsers.isSubsetOf(activeDrivers); // falseThese methods return new Sets, so they're immutable-friendly and chainable. No more manual loops, no third-party libraries for basic set math. Browser support is still rolling out, so check caniuse.com for your targets, but Node.js 22+ supports them all.
Iterator helpers — map, filter and take directly on iterators
Iterator helpers (TC39 Stage 3, likely Stage 4 soon) let you chain operations directly on iterators without converting to arrays. This is memory-efficient because operations are lazy—they don't evaluate until you consume the result.
In a travel booking system, imagine processing large search results from an API:
// Without iterator helpers, you'd convert to array
const flights = fetchFlightsGenerator(); // yields 10,000 flights
const cheapFlights = Array.from(flights)
.filter(f => f.price < 500)
.map(f => ({ id: f.id, price: f.price, duration: f.duration }))
.slice(0, 10); // All 10,000 converted to array, then filtered, then sliced
// With iterator helpers, it's lazy
const cheapFlights = flights
.filter(f => f.price < 500)
.map(f => ({ id: f.id, price: f.price, duration: f.duration }))
.take(10); // Only evaluates what .take() needs
// Consume it
for (const flight of cheapFlights) {
console.log(flight); // Only 10 flights processed, not 10,000
}The available methods are filter(), map(), flatMap(), take(), drop(), find(), some(), every(), and reduce(). They work on anything iterable—arrays, Sets, Maps, generators, and custom iterables.
This is especially valuable when building streaming APIs or processing paginated data. You can set up transformation pipelines that only materialize the data you actually need:
// Streaming example
async function* getUserPostsStream(userId) {
let page = 1;
while (true) {
const posts = await api.getPosts(userId, page);
if (posts.length === 0) break;
for (const post of posts) yield post;
page++;
}
}
// Process only recent posts from users in Australia
const recentAussiePosts = getUserPostsStream(userId)
.filter(p => p.createdAt > Date.now() - 7 * 24 * 60 * 60 * 1000)
.filter(p => p.author.country === 'AU')
.take(20); // Only fetches pages until 20 posts are foundSupport is still limited (primarily in V8 and SpiderMonkey), but it's worth understanding. When I'm building data-heavy services at Colombo, this pattern solves performance problems elegantly.
Common mistakes
- Confusing
||with??: Reaching for??reflexively when||is sometimes correct. Think through whether you want to treat0, empty strings, andfalseas valid values. Use||for boolean defaults,??for missing values. - Assuming optional chaining short-circuits evaluation:
obj?.method?.()won't call the method ifobjis nullish, but if the method exists and throws an error, that error propagates. It's not error-handling, it's property-access safety. - Forgetting Set methods are immutable:
set1.union(set2)returns a new Set; it doesn't mutateset1. If you need to accumulate results, assign to a new variable. - Converting iterators to arrays unnecessarily: If you're only iterating once or using
.take(), don't callArray.from(). You'll lose the performance benefit and defeat the purpose. - Using top-level await without thinking about module initialization order: If module B depends on module A's exports and module A uses top-level await, module B must also handle async initialization. Make sure your dependency graph is clear.
Key takeaways
- Optional chaining and nullish coalescing eliminate defensive nesting and make intent clear—use them in every project.
- Top-level await removes async wrapper patterns in ES modules, making initialization sequences more readable and maintainable.
- Set methods handle union, intersection, and difference natively—no more manual loops or library dependencies for basic set operations.
- Iterator helpers enable lazy evaluation and reduce memory overhead when processing large or infinite sequences.
- Browser and runtime support varies; check compatibility for your deployment targets, especially for Set methods and iterator helpers.
- These aren't breaking changes—they're quality-of-life improvements that let you focus on business logic instead of defensive coding patterns.






Comments
Loading comments…