ECMAScript 2024 features you can use now

ECMAScript 2024 is expected to be finalized in June, but four new JavaScript features are already supported in browsers and Node.js. Here's how to start using them today.

ECMAScript 2024 features you can use now
Roman Samborskyi/Shutterstock

The ECMAScript specification is like a portrait of the JavaScript language that is repainted every year. As is typical of modern JavaScript, the spec and real-world practice move in tandem. The newest version of the spec, ECMAScript 2024, includes seven new JavaScript features and is expected to be finalized in June. This article introduces four of the new features that are already available in browsers and server-side environments, and ready for you to use today:

  • Promise.withResolvers is a powerful mechanism for managing asynchronous operations when external control over resolution and rejection is necessary.
  • Object.groupBy and Map.groupBy let you organize collections based on key properties.
  • Atomics.waitAsync facilitates safe communication and synchronization between worker threads.
  • String.isWellFormed and String.toWellFormed add valuable tools for handling user input and network data.

Promise.withResolvers

Let’s start with the new static method on Promise, called withResolvers(). JavaScript promises give us various ways to deal with asynchronous operations. The withResolvers() method is used to create the three parts of a Promise: the Promise itself and the resolve() and reject() functions. 

The benefit of withResolvers() is that it creates all three as externally exposed references. In cases where you want to create a promise, and also have access to the resolution and rejection of the promise from external code, this is the method to use.

The spec itself is characteristically spartan in its description. The Mozilla documentation provides more detail. The key takeaway is that withResolvers() is equivalent to:


let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});
// use resolve and reject to control the promise

In the above snippet, we declare the resolve and reject references in the enclosing scope, then use them inside the body of the Promise callback to refer to the resolve and reject arguments. In this way, we're getting a handle on the promise callback from outside the callback itself.

The Promise.withResolvers() syntax is more compact and we don't have to declare the resolve and reject separately. With this method, we could write the exact same functionality as above like so:


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

The essence of this capability is that you use it when you need outside access to resolve() and reject(). This isn’t a very common scenario but it happens. Let’s consider a simple example:


function race(operation1, operation2) {
  const { racePromise, resolve, reject } = Promise.withResolvers();

  operation1().then(resolve).catch(reject);
  operation2().then(resolve).catch(reject);

  return racePromise;
}

function fetchData1() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Data from source 1"), 1000);
  });
}

function fetchData2() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Data from source 2"), 500);
  });
}

race(fetchData1, fetchData2)
  .then((data) => console.log("Winner:", data))
  .catch((error) => console.error("Error:", error));

Here, we have two operations, fetchData1() and fetchData2(), that return promises. They run timeouts, and fetchData2() is always fastest at 500 milliseconds. We use withResolvers() inside the race() function to expose the resolve and reject functions. These functions are then used by a new promise, called racePromise.

We then use the resolve and reject functions to respond to the two fetchData operations. This is interesting because you can see we don’t even provide a callback to racePromise. Instead, we control the promise externally. We use that control to bind the outcome of the other two promises to racePromise.

This is a contrived example, and somewhat unrealistic because the two racing promises do not begin at the same time. The point is to show the essence of how and when to use withResolvers().

Object.groupBy & Map.groupBy

The handy groupBy method is a quick way to organize collections based on a string value. It's a static method on Object and Map, which works on an array-like collection. The groupBy() method takes two arguments: the collection and a callback that operates on it. You get back a new object instance that has string label values as keys, and the corresponding array elements as the value.

So, when you have an array, and you need to divvy up the elements into string-labeled buckets according to some internal criteria, this is the method to use.

This is a fairly common thing that comes up in day-to-day coding. Looking at an example should make it clear. Say we have a collection of dog breeds and their size:


const dogBreeds = [
  { breed: 'Shih Tzu', size: 'Toy' },
  { breed: 'Leonberger', size: 'Giant' },
  { breed: 'King Charles Spaniel', size: 'Toy' },
  { breed: 'Great Pyrenees', size: 'Giant' },
  { breed: 'Corgi', size: 'Small' },
  { breed: 'German Shepherd', size: 'Large' },
];

Now, say we want to organize this collection by size. We want to end up with a collection of objects where the keys are the breed size and the values are the dog breed. Normally, we’d write a loop to do this but it's a bit finicky; it seems like there should be a better way. Now, with groupBy(), there is:


groupBy() is that better way:

Object.groupBy(dogBreeds, (x) => {
    return x.size;
})

This gives us output like so:

{ "Toy": [
    { "breed": "Shih Tzu", "size": "Toy" },
    { "breed": "King Charles Spaniel", "size": "Toy" }
  ],
  "Giant": [
    { "breed": "Leonberger", "size": "Giant" },
    { "breed": "Great Pyrenees", "size": "Giant" }
  ],
  "Small": [
    { "breed": "Corgi", "size": "Small" }
  ],
  "Large": [
    { "breed": "German Shepherd", "size": "Large" }
  ]
}

This gives you a simple, functional way to group collections of objects based on some property of the objects themselves. 

The groupBy() method takes whatever is returned by the callback and automatically collects all the elements that are equal according to String equality. If the return value is not a string, it’ll be coerced into a string. If it can’t be coerced, it’ll error out.

Atomics.waitAsync

The new Atomics.waitAsync() method is designed for sharing data across worker threads safely. It does not block the main thread like Atomics.wait() does. Because it is used between threads, it relies on the SharedArrayBuffer class. 

This class is disabled by default in modern browsers unless security requirements are met. In Node.js, however, the class is enabled by default. 

Here’s a simple example for usage in Node. Note that the imports are built into Node, so no NPM installs are required (but note that the import statement is):


// asyncWait.js
const { Worker, isMainThread, parentPort } = require('worker_threads');

const sharedBuffer = new SharedArrayBuffer(4); 
const int32Array = new Int32Array(sharedBuffer);

if (isMainThread) {
  const worker = new Worker(__filename);

  async function waitForMessage() {
    const initialValue = 0;
    const result = await Atomics.waitAsync(int32Array, 0, initialValue);
    if (result.async) {
      console.log("Message received from worker:", int32Array[0]);
    } else {
      console.error("Timeout waiting for worker message");
    }
  }

  waitForMessage(); 
} else {
  setTimeout(() => {
    Atomics.store(int32Array, 0, 1);
  }, 2000); 
}

To run this program, just enter: $ node asyncWait.js

The program declares a SharedArrayBuffer (wrapped around an int32Array) and then checks if we are on the main thread. If it is the main thread, we spawn a worker thread. (If you are new to worker threads, here's a good intro.)

The main thread waits for an update from the worker via the Atomics.waitAsync() call. The three args to waitAsync(1, 2, 3) are:

  1. Shared array (int32Array): The shared memory space.
  2. Element to watch (0): The index of the array to wait upon.
  3. Initial value (initialValue = 0): Only notify the main thread if this value is different (i.e., if the worker sets the value to the initial value of 0, a notification will not occur).

String.isWellFormed & String.toWellFormed 

User input, bad data, and network glitches are all sources of malformed strings. String.isWellFormed is a sanity check. It determines if a string is UTF-16 valid. UTF-16 is the encoding JavaScript itself uses, so String.isWellFormed() ensures a given string is something JavaScript can handle:


const string1 = "Hello, InfoWorld!";

const string2 = "Hello, \uD800world!";

console.log(string1.isWellFormed()); // Output: true (well-formed)
console.log(string2.isWellFormed()); // Output: false (malformed)

You can learn more about what constitutes valid strings in JavaScript in this section of the Mozilla reference. The bad guys in UTF-16 encoding are known as “lone surrogates.”

The active partner to String.isWellFormed, String.toWellFormed transforms a given string into something valid. Any lone surrogates found will be replaced by U+FFFD, the black diamond question mark character: �.


"Hello, \uD800world!".toWellFormed()
// outputs: 'Hello, �world!'

Conclusion

We’ve got a nice collection of new features in ECMAScript 2024. Promise.withResolvers() and Atomics.waitAsync() are more advanced use cases, while groupBy is a convenient addition that often comes in handy, and the new string methods are perfect for certain situations. All of these features are supported for JavaScript in browsers and server-side environments, so you can start using them today.

Copyright © 2024 IDG Communications, Inc.