Three JavaScript Features That'll Actually Change How You Code
JavaScript keeps sneaking in features that make you wonder how you ever lived without them. This post covers three of them — Set methods, Iterator Helpers, and Promise.try — that probably haven’t crossed your radar yet, but absolutely should.
The thing is, these aren’t flashy. They won’t make your app faster or smaller. But they’ll make your code feel better to write, and that matters more than you’d think.
A practical guide for developers at every level — from “what even is a Set?” to “here’s the edge case that’ll bite you in prod.”
1. New Set Methods — Finally, Set Algebra That Doesn’t Suck
The Old Way Was Painful
Sets are great for storing unique values. But the moment you needed to work with multiple Sets? You were back to writing loops and spreading arrays everywhere.
Want the union of two Sets? Spread them both into a new Set and hope you didn’t mess up the syntax.
Want to check if one Set is a subset of another? Time to write a helper function you’ll copy-paste into every project.
Those days are over.
Here’s What You Get
const a = new Set([1, 2, 3, 4, 5]);
const b = new Set([4, 5, 6, 7, 8]);
union() — Combine everything
a.union(b);
// Set { 1, 2, 3, 4, 5, 6, 7, 8 }
Before: new Set([...a, ...b])
After: a.union(b)
That’s it. That’s the whole thing.
intersection() — Find what’s in common
a.intersection(b);
// Set { 4, 5 }
Real example: You’ve got a “premium users” Set and a “beta testers” Set. Who’s in both?
const premiumUsers = new Set(['alice', 'bob', 'carol']);
const betaTesters = new Set(['bob', 'carol', 'dave']);
const premiumBetaTesters = premiumUsers.intersection(betaTesters);
// Set { 'bob', 'carol' }
difference() — What’s in A but not B
a.difference(b);
// Set { 1, 2, 3 }
Practical use: Find users who haven’t upgraded yet.
const allUsers = new Set(['alice', 'bob', 'carol', 'dave']);
const paidUsers = new Set(['bob', 'carol']);
const freeUsers = allUsers.difference(paidUsers);
// Set { 'alice', 'dave' }
symmetricDifference() — What’s in A or B, but not both
a.symmetricDifference(b);
// Set { 1, 2, 3, 6, 7, 8 }
This one’s useful for detecting changes. What was added or removed between two snapshots?
const oldTags = new Set(['javascript', 'typescript', 'react']);
const newTags = new Set(['typescript', 'react', 'bun', 'sqlite']);
const changedTags = oldTags.symmetricDifference(newTags);
// Set { 'javascript', 'bun', 'sqlite' }
// 'javascript' was removed, 'bun' and 'sqlite' were added
isSubsetOf() — Is A completely inside B?
const small = new Set([4, 5]);
small.isSubsetOf(a); // true — both 4 and 5 are in a
a.isSubsetOf(small); // false
isSupersetOf() — Does A contain all of B?
a.isSupersetOf(small); // true
small.isSupersetOf(a); // false
Permission checking is the classic use case:
const required = new Set(['read', 'write']);
const userPerms = new Set(['read', 'write', 'delete']);
if (userPerms.isSupersetOf(required)) {
console.log('Access granted');
}
isDisjointFrom() — Do A and B have zero overlap?
const x = new Set([1, 2, 3]);
const y = new Set([7, 8, 9]);
x.isDisjointFrom(y); // true — completely separate
x.isDisjointFrom(a); // false — they share 1, 2, 3
Scheduling conflicts? This is your method:
const aliceHours = new Set([9, 10, 11, 12]);
const bobHours = new Set([13, 14, 15, 16]);
const carolHours = new Set([11, 12, 13]);
aliceHours.isDisjointFrom(bobHours); // true — no conflict
aliceHours.isDisjointFrom(carolHours); // false — they overlap at 11, 12
How to use Set methods correctly
Set methods are straightforward — they work on finite Sets and return new Sets. No gotchas here:
// All of these are safe and predictable
const union = setA.union(setB);
const common = setA.intersection(setB);
const unique = setA.difference(setB);
// Check relationships
if (setA.isSupersetOf(setB)) {
// setA contains everything in setB
}
The key thing: Set methods always work on complete, finite data. You’re not dealing with lazy evaluation or infinite sequences. Call them, get a result, move on.
Pro tip: These methods accept any iterable with a
.sizeproperty, not just Sets. Maps work too (as[key, value]pairs). Mix and match if you need to, but watch out for type confusion.
2. Iterator Helpers — Lazy Evaluation Without the Pain
Why This Matters
Generators are cool because they produce values one at a time without loading everything into memory. But the second you wanted to transform that stream — filter it, map over it, take the first few — you had to convert it to an array. That defeats the whole purpose.
Iterator Helpers bring .map(), .filter(), .take() and friends directly to iterators. No array conversion. No memory explosion.
Start with an infinite generator
function* naturals() {
let i = 0;
while (true) {
yield i++;
}
}
This generates 0, 1, 2, 3, … forever. You can’t .map() infinity the old way. But now you can.
Understanding lazy evaluation
Here’s the critical thing to understand: creating an iterator doesn’t run it. It just sets up the pipeline.
// None of this actually runs yet — it's all lazy
const mapped = naturals().map(x => x * 2);
const filtered = mapped.filter(x => x % 2 === 0);
const pipeline = filtered.take(5);
// NOW it runs — when you consume the iterator
pipeline.toArray(); // [0, 4, 8, 12, 16]
The iterator only produces values when you ask for them. This is powerful, but it also means you need a stopping condition when consuming the iterator.
map() — Transform each value
const doubled = naturals().map(x => x * 2);
doubled.next().value; // 0
doubled.next().value; // 2
doubled.next().value; // 4
Still lazy. Nothing happens until you ask for the next value.
filter() — Keep only what matches
const odds = naturals().filter(x => x % 2 !== 0);
odds.next().value; // 1
odds.next().value; // 3
odds.next().value; // 5
take() — Stop after N values
naturals().take(5).toArray();
// [0, 1, 2, 3, 4]
This is your safety net. When working with infinite iterators, take() is how you prevent infinite loops.
drop() — Skip the first N
naturals().drop(5).take(5).toArray();
// [5, 6, 7, 8, 9]
Notice we still use take() to stop. Always pair drop() with a stopping condition.
flatMap() — Map then flatten one level
function* words() {
yield 'hello world';
yield 'foo bar';
}
words()
.flatMap(sentence => sentence.split(' '))
.toArray();
// ['hello', 'world', 'foo', 'bar']
Flattening paginated API results? This is your pattern:
function* pages() {
yield [1, 2, 3];
yield [4, 5, 6];
yield [7, 8, 9];
}
pages().flatMap(page => page).toArray();
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
This is safe because pages() is finite — it yields exactly 3 times, then stops.
forEach() — Run side effects
naturals()
.take(3)
.forEach(n => console.log(`Item: ${n}`));
// Item: 0
// Item: 1
// Item: 2
Always use take() before forEach() on infinite iterators. Without it, you’ll loop forever.
some() and every() — Short-circuit checks
// Does any value equal 5? (stops as soon as it finds one)
naturals().take(10).some(x => x === 5); // true
// Are all values less than 5? (stops as soon as it finds one that isn't)
naturals().take(10).every(x => x < 5); // false
These are lazy — some stops the moment it finds a match, every stops the moment it finds a mismatch. No wasted iterations.
find() — Get the first match
// First number divisible by both 3 and 5
naturals().find(x => x % 15 === 0);
// 0
naturals().drop(1).find(x => x % 15 === 0);
// 15
find() is safe on infinite iterators because it stops the moment it finds a match. No take() needed.
reduce() — Collapse into one value
// Sum of the first 5 natural numbers
naturals().take(5).reduce((acc, x) => acc + x, 0);
// 0 + 1 + 2 + 3 + 4 = 10
Again, take() is essential here. Without it, reduce() would try to sum infinity.
Chain them together for real power
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
// First 8 even Fibonacci numbers, squared
fibonacci()
.filter(n => n % 2 === 0)
.map(n => n ** 2)
.take(8)
.toArray();
// [0, 4, 64, 1156, 17956, 279841, 4373924, 68244900]
Every step is lazy. Only values that pass filter go to map, and we stop the moment we hit take(8).
⚠️ Critical: Infinite iterators need stopping conditions
This is where people get tripped up. If your iterator is infinite, you must have a way to prevent it from happening.
// DANGER: This loops forever and crashes
naturals().forEach(n => console.log(n));
// ❌ Infinite loop!
// SAFE: We have a stopping condition
naturals().take(5).forEach(n => console.log(n));
// ✅ Prints 5 values, then stops
// DANGER: toArray() on an infinite iterator
naturals().toArray();
// ❌ Runs out of memory!
// SAFE: Limit it first
naturals().take(100).toArray();
// ✅ Returns array of 100 values
// SAFE: some() and find() short-circuit automatically
naturals().some(x => x === 5);
// ✅ Stops when it finds 5
naturals().find(x => x > 1000);
// ✅ Stops when it finds a value > 1000
The rule: When consuming an infinite iterator, ask yourself: “How does this stop?” If the answer is “it doesn’t,” add take() or use a method that short-circuits like find() or some().
Real-world: Processing a massive log file
function* readLogs() {
// Imagine this yields lines from a 10GB log file
yield '[ERROR] Disk full';
yield '[INFO] User logged in';
yield '[ERROR] Connection timeout';
yield '[WARN] High memory usage';
yield '[ERROR] DB query failed';
}
const firstTwoErrors = readLogs()
.filter(line => line.startsWith('[ERROR]'))
.take(2)
.toArray();
// ['[ERROR] Disk full', '[ERROR] Connection timeout']
With arrays, you’d load the entire file into memory first. With iterators, you stop reading the moment you have what you need.
Real-world: Processing a stream of events
function* eventStream() {
// Imagine this yields events from a WebSocket
while (true) {
yield getNextEvent();
}
}
// Get the first 10 click events
const clicks = eventStream()
.filter(e => e.type === 'click')
.take(10)
.toArray();
// Process them
clicks.forEach(click => handleClick(click));
Without take(), this would wait forever for events. With it, we process exactly 10 and move on.
Pro tip: Iterator helpers are lazy by design — they don’t do any work until you consume the iterator. Calling
.map()alone doesn’t run your callback even once. This is great for performance, but it also means if your generator has side effects, they won’t fire until the iterator is consumed. Keep generators pure when possible.
3. Promise.try — One .catch() to Rule Them All
The Problem (And It’s Real)
You call a function that might throw synchronously or might return a rejected promise. Handling both cases cleanly used to be a mess:
let promise;
try {
promise = Promise.resolve(riskyFunction());
} catch (err) {
promise = Promise.reject(err);
}
promise.then(doSomething).catch(handleError);
That’s verbose, easy to forget, and easy to get wrong. Promise.try fixes it.
Basic usage
Promise.try(getUser, 1)
.then(user => console.log(user))
.catch(err => console.error(err));
If getUser throws synchronously, Promise.try catches it.
If getUser returns a rejected promise, Promise.try catches that too.
One .catch() for both cases.
Sync throws and async rejections — handled identically
This is the key insight. Without Promise.try, synchronous throws escape the promise chain:
function mightThrowSync(id) {
if (typeof id !== 'number') throw new TypeError('id must be a number');
return fetch(`/api/user/${id}`).then(r => r.json());
}
// WITHOUT Promise.try — the synchronous throw escapes
mightThrowSync('oops') // throws here, before the promise chain starts
.then(console.log)
.catch(console.error); // ❌ never reached! The error crashes the app
// WITH Promise.try — both sync throws and async rejections are caught
Promise.try(mightThrowSync, 'oops')
.then(console.log)
.catch(console.error); // ✅ catches the TypeError cleanly
The difference is subtle but critical. Promise.try wraps the function call in a way that catches both synchronous throws and asynchronous rejections.
How it works
// Promise.try is essentially this:
Promise.try(fn, arg)
// ≈ new Promise(resolve => resolve(fn(arg)))
// If fn throws synchronously, the Promise constructor catches it
// If fn returns a promise, resolve() chains to it
// If fn returns a value, resolve() wraps it in a resolved promise
Great as an async entry point
Promise.try(async () => {
const user = await getUser(1);
const posts = await getUserPosts(user.id);
return posts.filter(p => p.published);
})
.then(posts => console.log(`Found ${posts.length} posts`))
.catch(err => console.error('Something went wrong:', err.message));
Cleaner than wrapping everything in a top-level async IIFE with try/catch.
Real-world: Safe route handlers
function createHandler(fn) {
return (req, res) => {
Promise.try(fn, req)
.then(result => res.json({ data: result }))
.catch(err => res.status(500).json({ error: err.message }));
};
}
const getProfile = createHandler(async (req) => {
const user = await db.findUser(req.params.id); // async, might reject
if (!user) throw new Error('Not found'); // sync throw
return user;
});
Both kinds of errors funnel to the same .catch(). No try/catch inside fn, no forgotten error paths. Every error — whether it’s a database rejection or a validation throw — gets handled the same way.
When to use Promise.try
Use Promise.try when:
- You’re calling a function that might throw synchronously
- You want one error handler for both sync and async failures
- You’re wrapping user-provided functions (plugins, callbacks, etc.)
- You’re building a framework or library that needs robust error handling
// Good use case: wrapping a user-provided callback
function runPlugin(pluginFn, data) {
return Promise.try(pluginFn, data)
.catch(err => console.error(`Plugin failed: ${err.message}`));
}
// Good use case: safe API endpoint
app.get('/api/user/:id', (req, res) => {
Promise.try(getUser, req.params.id)
.then(user => res.json(user))
.catch(err => res.status(500).json({ error: err.message }));
});
Pro tip:
Promise.tryis essentially sugar fornew Promise(resolve => resolve(fn())). The magic is thatresolve()automatically handles thenables — iffn()returns a promise, it chains correctly. Iffn()throws synchronously, thenew Promiseconstructor catches it and rejects.Promise.tryjust makes that pattern readable and intention-revealing.
The Takeaway
| Feature | When it ships | Why it matters | Key gotcha |
|---|---|---|---|
| Set methods | ES2025 | No more manual loops for set algebra | None — they’re straightforward |
| Iterator Helpers | ES2025 | Lazy, composable pipelines without arrays | Infinite iterators need stopping conditions |
| Promise.try | ES2025 | One error handler for sync and async failures | None — it just works |
All three are shipping now or behind a flag in the latest V8/SpiderMonkey/JavaScriptCore. Bun supports all of them today.
The pattern across all three is the same: less boilerplate, more clarity. Your code gets shorter, and the next person reading it immediately understands what you’re doing, not how you’re doing it.
Quick reference: Using them correctly
Set methods:
// Just call them — they're safe
const result = setA.union(setB);
Iterator Helpers:
// Always have a stopping condition for infinite iterators
infinite().take(10).toArray(); // ✅ safe
infinite().find(x => x > 100); // ✅ safe (short-circuits)
infinite().forEach(x => log(x)); // ❌ infinite loop!
infinite().take(5).forEach(x => log(x)); // ✅ safe
Promise.try:
// Use it when you need to catch both sync and async errors
Promise.try(riskyFunction, arg)
.then(result => handleSuccess(result))
.catch(err => handleError(err));
That’s worth paying attention to.