What's New in ES2024 and ES2025?


As I explain in my Modern JS with ES6+ post, the TC39 is the group that is responsible for advancing the ECMAScript specifications and standardizing these specifications for the JavaScript language. Ever since the 2015 release of ES2015 or ES6, we’ve had a yearly release cycle for the next version of ECMAScript. Before a feature can be added to a release, it must first go through four proposal stages before finally being approved for release. A few years back I covered the features added in ES2021, so let’s catch up on what was added in ES2024 and ES2025.

You can check out all of the finished proposals, from across the years, on GitHub.

ES2024

Object.groupBy / Map.groupBy

Syntax
Object.groupBy(iterable, callbackFn);
Map.groupBy(iterable, callbackFn);

The Object.groupBy static method accepts an iterable and a callback function as its parameters and returns a plain object where each key holds an array of the matching items. The callback should return the key that you want to group the items by. The Map.groupBy method works exactly the same way, but returns a Map instead, which is useful when your keys are not strings.

Examples
const people = [
  { name: "Kevin", role: "engineer" },
  { name: "James", role: "designer" },
  { name: "Bobby", role: "engineer" },
  { name: "Nicole", role: "designer" },
  { name: "Jim", role: "engineer" },
];
 
const byRole = Object.groupBy(people, ({ role }) => role);
// {
//   engineer: [{ name: "Kevin", ... }, { name: "Bobby", ... }, { name: "Jim", ... }],
//   designer: [{ name: "James", ... }, { name: "Nicole", ... }],
// }

Before this, the typical approach was to reach for Array.prototype.reduce with a manual accumulator, so this is a much cleaner option. It’s important to note that the returned object has a null prototype, so it won’t have .hasOwnProperty or any other Object.prototype methods on it. If you need to check keys, use Object.hasOwn() instead.

Promise.withResolvers

Syntax
const { promise, resolve, reject } = Promise.withResolvers();

If you’ve ever needed to resolve or reject a promise from outside of its constructor, you’ve probably written a version of this:

Before
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Promise.withResolvers() is a built-in version of that pattern. The returned promise, resolve, and reject are all scoped together, which reads a lot more naturally.

Example
const { promise, resolve, reject } = Promise.withResolvers();
 
document.getElementById("submit-btn").addEventListener("click", () => {
  resolve("submitted");
});
 
document.getElementById("cancel-btn").addEventListener("click", () => {
  reject(new Error("cancelled"));
});
 
const result = await promise;

String.prototype.isWellFormed / toWellFormed

Syntax
str.isWellFormed();
str.toWellFormed();

JavaScript strings are UTF-16 internally, which means that they can contain lone surrogates. A lone surrogate is half of a surrogate pair with no matching partner. These are technically invalid Unicode and will cause encodeURIComponent to throw, among other issues.

Examples
const good = "Hello, world!";
const bad = "\uD800"; // lone high surrogate
 
good.isWellFormed(); // true
bad.isWellFormed(); // false
 
bad.toWellFormed(); // "?" (replaces lone surrogates with U+FFFD)
 
// Before, this would throw a URIError:
encodeURIComponent(bad.toWellFormed()); // "%EF%BF%BD"

Most app developers won’t run into this very often, but it is a great defensive tool to have when you are accepting arbitrary string input from external sources.

RegExp v flag (Unicode Sets)

The new /v flag is a more powerful upgrade to the existing /u flag for Unicode-aware regular expressions. It adds set notation inside character classes ([A--B], [A&&B], [A||B]) as well as string literals in character classes, which makes working with multi-codepoint characters far more practical.

Example
// Match any Greek letter that is NOT a vowel
const greekConsonant = /[\p{Script=Greek}--[αεηιοωυ]]/v;

If you aren’t doing advanced Unicode work in regex, you won’t reach for this often, but it is a significant improvement over /u when you do need it.

ES2025

Iterator Helpers

Iterators in JavaScript have always been powerful in theory but painful in practice. You couldn’t chain .map(), .filter(), or .take() on them without first materializing the whole thing into an array.

Iterator helpers add lazy, chainable methods directly to the iterator protocol:

MethodDescription
.map(fn)Transforms each value
.filter(fn)Keeps values where fn returns truthy
.take(n)Takes the first n values
.drop(n)Skips the first n values
.flatMap(fn)Maps then flattens one level
.reduce(fn, init)Reduces to a single value
.toArray()Materializes the iterator into an array
.forEach(fn)Iterates for side effects
.some(fn)Returns true if any value passes
.every(fn)Returns true if all values pass
.find(fn)Returns first matching value
Examples
function* naturals() {
  let n = 0;
  while (true) yield n++;
}
 
// Without materializing the whole sequence:
const firstFiveEvenSquares = naturals()
  .filter((n) => n % 2 === 0)
  .map((n) => n ** 2)
  .take(5)
  .toArray();
 
// [0, 4, 16, 36, 64]

The chain is lazy, which means that .take(5) will only ever pull five values through the entire chain. This works on anything that implements the iterator protocol, including Map, Set, and generator functions.

Set Methods

If you’ve ever needed to find the overlap between two arrays by converting them to Sets and writing manual loops, this proposal is for you. ES2025 adds seven new methods directly to Set.prototype:

Examples
const frontend = new Set(["React", "TypeScript", "CSS", "Node.js"]);
const backend = new Set(["Node.js", "PostgreSQL", "Redis", "TypeScript"]);
 
frontend.intersection(backend);
// Set { "TypeScript", "Node.js" }
 
frontend.union(backend);
// Set { "React", "TypeScript", "CSS", "Node.js", "PostgreSQL", "Redis" }
 
frontend.difference(backend);
// Set { "React", "CSS" }
 
frontend.symmetricDifference(backend);
// Set { "React", "CSS", "PostgreSQL", "Redis" }
 
frontend.isSubsetOf(backend); // false
frontend.isSupersetOf(backend); // false
frontend.isDisjointFrom(new Set(["Python", "Go"])); // true

It’s important to note that all seven methods accept any iterable as their argument, not just another Set, which makes them even more flexible.

Promise.try

Syntax
Promise.try(callbackFn);

Promise.try wraps a function in a promise and catches both synchronous throws and async rejections uniformly. Before this, if a function might be async, you had to guard against synchronous errors separately:

Before
// This doesn't catch synchronous throws from getUser():
const result = Promise.resolve().then(() => getUser(id));
 
// Or the more explicit version:
let p;
try {
  p = Promise.resolve(getUser(id));
} catch (err) {
  p = Promise.reject(err);
}
After
const result = await Promise.try(() => getUser(id));

Both synchronous throws and returned rejected promises will flow through the same .catch() handler.

Import Attributes

Syntax
import data from "./data.json" with { type: "json" };

Import attributes (previously called import assertions, using the assert keyword) let you attach metadata to import statements. The main practical use today is JSON modules. You can now import a .json file directly without needing a bundler transform.

Examples
import config from "./config.json" with { type: "json" };
 
console.log(config.version); // works like any other object
 
// Dynamic imports work too:
const data = await import("./data.json", { with: { type: "json" } });

It’s important to note that the syntax has changed from assert to with. If you were using the older assert syntax (which was available in some runtimes early on), you’ll want to update it.

Error.isError

Syntax
Error.isError(value);

A reliable way to check if a value is an Error instance has been surprisingly elusive. instanceof Error fails across realms (iframes, worker contexts), and value?.constructor?.name === "Error" misses subclasses. The Error.isError() method checks the internal structure of the value and works correctly across realm boundaries.

Examples
Error.isError(new Error("oops")); // true
Error.isError(new TypeError("bad")); // true
Error.isError({ message: "not real" }); // false
Error.isError("a string"); // false

It’s a small addition, but it solves a real problem for library authors and anyone doing serious error handling across async boundaries.

That covers the major features added in ES2024 and ES2025. As always, you can keep an eye on what is in flight on the TC39 proposals repo.