Marcell CD

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 .size property, 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 lazysome 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:

// 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.try is essentially sugar for new Promise(resolve => resolve(fn())). The magic is that resolve() automatically handles thenables — if fn() returns a promise, it chains correctly. If fn() throws synchronously, the new Promise constructor catches it and rejects. Promise.try just 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.