DEV Community

Cover image for 20 JS Features That Already Exist — Stop Writing the Polyfill
Nishant Gaurav
Nishant Gaurav

Posted on

20 JS Features That Already Exist — Stop Writing the Polyfill

Let me be upfront about something.

I was reviewing a PR last month — someone on my team had written a 14-line groupBy helper function, complete with JSDoc comments and unit tests. Solid code, honestly. Clean. Well-tested.

It was also completely unnecessary. Object.groupBy() has been in the language since 2024.

Nobody blamed him. I'd done the same thing myself six months earlier. That's just how it goes with JavaScript — the language keeps moving, and unless you're actively tracking TC39 proposals, you end up writing polyfills for things that already exist natively.

So this is that post. The one I wish someone had handed me.

I'm covering 20 features from ES2021 through ES2025. Some of them you've probably seen. Others are the kind of thing where you read the example and immediately start thinking about three places in your current project where you've been doing it the hard way.

Fair warning: I'm skipping the obvious stuff. No optional chaining (?.), no nullish coalescing (??). Those have been around long enough that they're basically table stakes at this point. Everything here is from 2021 or newer.

Let's go.


ES2021 — The Underappreciated Year

1. Logical Assignment Operators (&&=, ||=, ??=)

I genuinely don't understand why these don't get more attention.

You've almost certainly written this:

// Assign default only if value is null or undefined
if (user.preferences == null) {
  user.preferences = {};
}
Enter fullscreen mode Exit fullscreen mode

Or this variation that looks clever until you realize it has a subtle bug with falsy values:

user.role = user.role || "viewer";
Enter fullscreen mode Exit fullscreen mode

Now there are three operators that handle these exact cases, following the same mental model as += or -=:

user.preferences ??= {};       // only assign if null/undefined
config.debug &&= isDevMode();  // only assign if left side is truthy
settings.theme ||= "dark";     // assign if left side is falsy
Enter fullscreen mode Exit fullscreen mode

The ??= one is the real gem. It respects the difference between "this value is missing" versus "this value happens to be false or zero" — which ||= doesn't. If you've ever had a bug because || was eating a valid 0 or false, you know exactly what I mean.


2. WeakRef and FinalizationRegistry

Okay, this one is niche. I'll admit that upfront.

Most frontend developers will never need this. But if you've ever built something like a component tree, an in-memory cache, or anything involving large objects with potentially complex lifetimes — read on.

const cache = new Map();

function remember(key, obj) {
  cache.set(key, new WeakRef(obj));
}

function recall(key) {
  const ref = cache.get(key);
  return ref?.deref(); // returns the object, or undefined if GC'd
}
Enter fullscreen mode Exit fullscreen mode

WeakRef lets you hold a reference to an object without preventing it from being garbage collected. If the object gets cleaned up, deref() returns undefined. No crashes. No memory leaks.

FinalizationRegistry pairs with it — you register a callback that fires after an object is collected:

const registry = new FinalizationRegistry((label) => {
  console.log(`${label} was garbage collected`);
});

registry.register(bigObject, "my-big-object");
Enter fullscreen mode Exit fullscreen mode

MDN has a very honest warning about this: don't use it for critical application logic. GC timing is non-deterministic. But for caches, event tracking, or debug tooling? Really useful.


ES2022 — The Foundation Everyone Built On

3. Top-Level await

Before this, using await at the top level of a file meant wrapping everything in an immediately-invoked async function:

// The old tax you had to pay
(async () => {
  const config = await loadConfig();
  startApp(config);
})();
Enter fullscreen mode Exit fullscreen mode

Now:

const config = await loadConfig();
startApp(config);
Enter fullscreen mode Exit fullscreen mode

That's it. It only works inside ES modules (files with type: "module" or .mjs extension), but that's increasingly the default anyway.

What I like most about this is how it cleans up initialization code. Config loading, database connections, feature flag fetches — all the stuff that happens before your app is "ready" — can now be written as flat, readable code instead of callback pyramids.


4. Private Class Fields (#)

JavaScript has always had "private" fields in the same way that a diary with "PRIVATE" written on the cover is private. Technically nothing stops anyone from reading it.

class ApiClient {
  _secret = "totally-not-private"; // anyone can do client._secret
}
Enter fullscreen mode Exit fullscreen mode

Private class fields with # are actually private. The runtime enforces it:

class ApiClient {
  #apiKey;

  constructor(key) {
    this.#apiKey = key;
  }

  makeRequest(endpoint) {
    return fetch(endpoint, {
      headers: { Authorization: `Bearer ${this.#apiKey}` }
    });
  }
}

const client = new ApiClient("abc123");
console.log(client.#apiKey); // SyntaxError — genuinely inaccessible
Enter fullscreen mode Exit fullscreen mode

You can also have private methods (#doSomething()) and private static fields. If you're writing class-based code and doing anything sensitive, this is worth adopting.


5. Error.cause

Here's a pattern that's saved me hours of debugging:

async function loadUserProfile(userId) {
  try {
    const raw = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
    return parseUserData(raw);
  } catch (err) {
    throw new Error(`Failed to load profile for user ${userId}`, {
      cause: err
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The cause property chains errors together. When you log the outer error, you can inspect .cause to find the original one. No more "something failed" errors with zero context about why.

This pairs really well with structured logging. In production systems where you're shipping errors to Datadog or Sentry, the full cause chain shows up cleanly rather than getting swallowed.


6. Object.hasOwn()

Short one, but worth knowing.

// What we used to write — and yes, this is as ugly as it looks
if (Object.prototype.hasOwnProperty.call(obj, "key")) { ... }

// What we write now
if (Object.hasOwn(obj, "key")) { ... }
Enter fullscreen mode Exit fullscreen mode

The reason the old way was so verbose is that hasOwnProperty can be overridden on objects, and on objects created with Object.create(null) it doesn't exist at all. Object.hasOwn() bypasses all of that. Same behavior, no footguns.


7. .at() — Relative Array Indexing

const scores = [88, 92, 71, 95, 83];

// Before
const last = scores[scores.length - 1];

// Now
const last = scores.at(-1);   // 83
const second = scores.at(-2); // 95
Enter fullscreen mode Exit fullscreen mode

Negative indices count from the end. Works on arrays, strings, and TypedArrays.

The main complaint I see about .at() is that it returns undefined rather than throwing when you go out of bounds — which is the same behavior as bracket notation, so this isn't really new territory.


8. structuredClone()

This one deserves way more attention than it gets.

The old approach to deep-cloning objects in JavaScript was a hack that everyone used and nobody was proud of:

const deepCopy = JSON.parse(JSON.stringify(original));
Enter fullscreen mode Exit fullscreen mode

That works until your object contains a Date (gets stringified), a Map (silently becomes {}), a Set (same), a function (gets dropped), or anything circular (throws).

structuredClone() handles all of this:

const clone = structuredClone(original);
Enter fullscreen mode Exit fullscreen mode

Preserves Dates, Maps, Sets, ArrayBuffers, RegExps. Handles circular references. Works in browsers and Node.js. No libraries needed.

The only things it won't clone are functions, DOM nodes, and class instances (it copies the data but loses the prototype). For plain data objects — which is most of what you're actually cloning — it's exactly what you need.


ES2023 — The Immutability Push

9. toSorted(), toReversed(), toSpliced()

The original sort(), reverse(), and splice() all mutate in place. This has caused so many subtle bugs in codebases that tracking one down is practically a rite of passage.

const original = [3, 1, 4, 1, 5, 9];

// Old way — mutates original, easy to forget
const sorted = [...original].sort((a, b) => a - b);

// New way — original is untouched
const sorted = original.toSorted((a, b) => a - b);
const reversed = original.toReversed();
const spliced = original.toSpliced(2, 1, 99); // replaces index 2 with 99
Enter fullscreen mode Exit fullscreen mode

These are particularly valuable in React and other state-driven frameworks where mutating state directly causes rendering bugs. You can now pass array data around without having to defensively clone everything before operating on it.


10. .with() — Immutable Index Assignment

This is the one most people miss completely from ES2023.

toSorted() and toReversed() are the non-mutating versions of sort() and reverse(). .with() is the non-mutating version of direct index assignment.

const prices = [10, 20, 30, 40];

// Old way to update one element without mutating
const updated = prices.map((price, i) => i === 2 ? 99 : price);

// New way
const updated = prices.with(2, 99);
// prices is still [10, 20, 30, 40]
// updated is [10, 20, 99, 40]
Enter fullscreen mode Exit fullscreen mode

One line. No spread. No map. No mutation. In Redux reducers or any immutable update pattern, this is a nice quality-of-life improvement.


11. findLast() and findLastIndex()

find() searches from the start. Sometimes you want the last match.

const events = [
  { type: "click", time: 100 },
  { type: "scroll", time: 200 },
  { type: "click", time: 300 },
  { type: "scroll", time: 400 },
];

// Old way — reverses array first (and mutates without the spread)
const lastClick = [...events].reverse().find(e => e.type === "click");

// New way
const lastClick = events.findLast(e => e.type === "click");
// { type: "click", time: 300 }
Enter fullscreen mode Exit fullscreen mode

The old approach had two problems: it mutated (hence the spread), and the intent wasn't immediately obvious. findLast() just says what it means.


ES2024 — Async and Data Handling

12. Promise.withResolvers()

Before this, if you needed to resolve or reject a promise from outside its own constructor — a common pattern in event-driven code — you had to do something like this:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});
Enter fullscreen mode Exit fullscreen mode

That let hanging out there always felt wrong. The assignment inside the callback is a side effect that's easy to miss.

const { promise, resolve, reject } = Promise.withResolvers();
Enter fullscreen mode Exit fullscreen mode

Same thing, but explicit. Useful for wrapping callback-based APIs, building queues, or coordinating async state between components.


13. Object.groupBy()

Let me give the full before/after:

const transactions = [
  { id: 1, type: "credit", amount: 500 },
  { id: 2, type: "debit", amount: 200 },
  { id: 3, type: "credit", amount: 150 },
  { id: 4, type: "debit", amount: 75 },
];

// Old way
const grouped = transactions.reduce((acc, t) => {
  (acc[t.type] ??= []).push(t);
  return acc;
}, {});

// New way
const grouped = Object.groupBy(transactions, t => t.type);
// {
//   credit: [{ id: 1, ... }, { id: 3, ... }],
//   debit:  [{ id: 2, ... }, { id: 4, ... }]
// }
Enter fullscreen mode Exit fullscreen mode

The reduce version isn't wrong. But it requires you to slow down and parse it. groupBy communicates intent instantly.

There's also Map.groupBy() for when you need non-string keys.


14. Array.fromAsync()

Array.from() converts iterables into arrays. Array.fromAsync() does the same for async iterables.

async function* streamLines(url) {
  const response = await fetch(url);
  for await (const line of response.body.getReader()) {
    yield line;
  }
}

const lines = await Array.fromAsync(streamLines("https://example.com/data.txt"));
Enter fullscreen mode Exit fullscreen mode

Before this, collecting an async iterable into an array meant writing a for await loop and pushing manually. Not difficult, but boilerplate you shouldn't have to write. More useful in Node.js environments than in the browser — streams, file reading, any async data source that yields over time.


15. Promise.try()

The gap this fills is small but real.

Sometimes you call a function not knowing if it's synchronous or asynchronous. If it's sync and it throws, you want that caught as a rejected promise. If it's async, you want the rejection from the promise itself. Making both behave the same way used to require a wrapper:

// Old way to unify sync/async error handling
Promise.resolve()
  .then(() => mightBeAsyncOrMightThrow())
  .catch(handleError);

// New way
Promise.try(() => mightBeAsyncOrMightThrow())
  .catch(handleError);
Enter fullscreen mode Exit fullscreen mode

Promise.try() is just more explicit about what you're doing — wrapping a call in a promise context to normalize error handling.


ES2025 — JavaScript Gets Serious About Functional Patterns

16. Iterator Helpers

This is the one that takes a little explaining, but it's worth it.

When you chain .map() and .filter() on arrays, JavaScript builds a complete new array at each step:

const result = bigArray
  .map(transform)    // new array: full length
  .filter(keep)      // another new array
  .slice(0, 10);     // yet another one
Enter fullscreen mode Exit fullscreen mode

If bigArray has 100,000 elements and you only need 10 results, you've done a lot of unnecessary work.

Iterator helpers process lazily — each element goes through the whole pipeline before the next one starts, and processing stops the moment you have enough:

const result = bigArray.values()
  .map(transform)
  .filter(keep)
  .take(10)
  .toArray();
Enter fullscreen mode Exit fullscreen mode

Same output. No intermediate arrays. The take(10) means the moment you have 10 results, everything stops.

Other available helpers: .drop(), .flatMap(), .forEach(), .reduce(), .some(), .every(), .find(). Everything you'd expect from array methods, but lazy.


17. New Set Methods

Sets have been in JavaScript since ES6, but for almost a decade the only way to do set operations was to convert to arrays first. ES2025 finally adds the math:

const admins = new Set(["alice", "bob", "carol"]);
const active = new Set(["bob", "carol", "dave", "eve"]);

admins.intersection(active);        // Set {"bob", "carol"}
admins.union(active);               // Set {"alice", "bob", "carol", "dave", "eve"}
admins.difference(active);          // Set {"alice"}
active.difference(admins);          // Set {"dave", "eve"}
admins.symmetricDifference(active); // Set {"alice", "dave", "eve"}

admins.isSubsetOf(active);    // false
admins.isSupersetOf(active);  // false
admins.isDisjointFrom(active);// false
Enter fullscreen mode Exit fullscreen mode

The old way just for intersection:

const intersection = new Set([...admins].filter(x => active.has(x)));
Enter fullscreen mode Exit fullscreen mode

Not unreadable in isolation, but once you're chaining multiple set operations — or explaining the code to someone — native methods win clearly.


18. RegExp.escape()

This one matters most in security-sensitive contexts.

Building a regex from user input without escaping it is a classic way to introduce bugs — or deliberately exploitable behavior:

// User types "hello.world" — the dot matches ANY character
const regex = new RegExp(userInput); // potentially dangerous
Enter fullscreen mode Exit fullscreen mode

The old fix was a utility function that most codebases copy-pasted from Stack Overflow:

function escapeRegExp(str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Enter fullscreen mode Exit fullscreen mode
// New way
const regex = new RegExp(RegExp.escape(userInput));
Enter fullscreen mode Exit fullscreen mode

Same result, but now it's a standard language feature rather than something you wrote, or copied, or imported, and might have gotten subtly wrong.


19. Float16Array

This one is for a specific audience, and if you're in that audience you've probably been waiting for it.

JavaScript's default number type is 64-bit float. Float32Array has been available for a while. ES2025 adds Float16Array:

const weights = new Float16Array(1024);
Enter fullscreen mode Exit fullscreen mode

16-bit floats use half the memory of 32-bit and a quarter of 64-bit. The trade-off is precision — roughly 3 decimal digits instead of 7 (Float32) or 15 (Float64).

Where this matters: WebGPU and WebGL applications, machine learning inference in the browser, any situation where you're transferring large volumes of numeric data to/from GPU memory. Smaller means faster transfer, less memory bandwidth, better throughput.


20. Explicit Resource Management (using)

Saving the best for last.

Anyone who's written cleanup code knows the pattern — open a connection, do work, close the connection, but also close it if something throws, so you need a try/finally block, and now your actual logic is buried in ceremony:

const connection = await db.connect();
try {
  await connection.query("...");
} finally {
  await connection.close();
}
Enter fullscreen mode Exit fullscreen mode

using is JavaScript's version of Python's with statement or C#'s using block:

await using connection = await db.connect();
await connection.query("...");
// connection.close() runs automatically when the block exits
// even if an error is thrown
Enter fullscreen mode Exit fullscreen mode

For this to work, the object needs to implement Symbol.asyncDispose (or Symbol.dispose for sync resources). Any class can implement this interface.

This is Stage 4 in TC39 (finalized) and already available in TypeScript 5.2+. Native browser and Node.js support is rolling out now.


The Pattern You Can't Miss

If you look at these 20 features together, something becomes obvious.

Immutability by default (toSorted, toReversed, with). Safer async patterns (Promise.withResolvers, Promise.try). Real encapsulation (#privateFields). Automatic cleanup (using). Better debugging chains (Error.cause). Lazy evaluation instead of eager allocation (iterator helpers). Native set math. Safe regex construction.

JavaScript is not becoming a different language. It's becoming a safer version of itself — filling in the gaps where experienced developers had learned to be careful, and making the right approach easier than the wrong one.

The features from 2015 onwards were largely about adding missing capabilities. What we're seeing now is the language addressing the ways those capabilities got misused.


That's 20 features. Some you'll use every week, some once a year, some maybe never depending on what you build. But all of them are things worth knowing exist.

Because the next time someone on your team writes a 14-line groupBy helper — clean, well-tested, completely unnecessary — you want to be the one who catches it in review.


Found something I got wrong, or a feature I should've included? Drop a comment. I read them all.

Top comments (0)