JavaScript is a living language, which means that it's constantly evolving. This process is managed by the TC39 committee — a group of delegates from various large tech companies who oversee the JavaScript language. These delegates meet together a few times a year to decide which proposals will be advanced between the five stages of consideration. Once a proposal reaches Stage 4, it is deemed "finished" and added to the ECMAScript specification, ready to be used by JavaScript engines and developers.

This year, five proposals made the cut. All of these features are included in the latest versions of modern browsers, so feel free to use them in your projects. In this post, we'll dive into what each of these proposals is about and how you can use them to improve your JavaScript code.

Logical Assignment Operators

You already know about the assignment operator. It lets you put values into variables.

let postAuthor = "Tyler";
postAuthor = "Alex";

You also likely know about logical operators, which return either true or false based on some logical operation. They include the AND operator (&&), the OR operator (||), and the recently added nullish coalescing operator (??).

Finally, you know about the mathematical assignment operators. These let you perform a mathematical operation on a variable with the value you are assigning, such as currentNum += 5 which adds 5 to the value of currentNum.

TC39 decided it was time to introduce these operators to each other and created Logical Assignment Operators, which do some logic on the value in the variable when deciding whether to assign a value to it. We'll look at each logical assignment operator individually.

&&=

You can pronounce this as "And And Equals". When you use this, it only assigns a new value to the variable if the variable's current value is truthy — the truthiness of the new value doesn't matter. These two statements are roughly equivalent.

// Without Logical Operators
a && (a = b);
// With Logical Operators
a &&= b;

To demonstrate this, lets create an object called "favorites" and try adding some lists of favorites to it.

let favorites = {};
// Without first creating the property,
// this won't add the property to the object
favorites.favoriteNumbers &&= [5];
console.log(favorites); // {}
// We'll add an empty array
favorites.favoriteNumbers = [];
// Now when we assign to this property,
// the assignment will work, since it already exists
favorites.favoriteNumbers &&= [15];
console.log(favorites); //{favoriteNumbers: [15]}

In this case, if the property doesn't exist, it doesn't create the property. But if it already exists, it overwrites it with the value we provide.

||=

You can call this one "Or Or Equals". It works similarly to &&=, except instead of checking to see if the existing value is truthy, it only assigns the new value if the existing value is falsy.

// Without Logical Operators
a || (a = b);
// With Logical Operators
a ||= b;

Once again, we'll add a property to a "favorites" object to demonstrate it's behavior.

let favorites = {};
// Without first creating the property,
// this will assign it. Useful for initializing the array.
favorites.favoriteColors ||= [];
console.log(favorites); // {favoriteColors: []}
// Now that the property has been initialized,
// we can't change it with ||=
favorites.favoriteColors ||= ["red"];
console.log(favorites); // {favoriteColors: []}

??=

This one is pronounced QQ Equals, and it is exactly the same as ||= except it checks if the existing value is nullish, meaning either null or undefined. If it is, it will assign the new value. These two statements work the same.

// Without Logical Operators
a ?? (a = b);
// With Logical Operators
a ??= b;

We'll take one more look at how we can use this with a "favorites" object.

let favorites = {};
// Since properties are undefined before initialized,
// we can use ??= to set an initial, or default, value
favorites.favoriteColorCount ??= 0;
console.log(favorites); // {favoriteColorCount: 0}
// Once we've initialized the property,
// we can't change it with ??=, even if it's 0
favorites.favoriteColorCount ??= 10;
console.log(favorites); // {favoriteColorCount: 0}
// If we reset the value by setting it to null
// we can set it with ??= again
favorites.favoriteColorCount = null;
favorites.favoriteColorCount ??= 10;
console.log(favorites); // {favoriteColorCount: 10}

Notice that it doesn't assign the property when it's value is 0, because that value isn't nullish.


Why would you use this? These operators can save you a little bit of effort as you are assigning values to other values or object properties based on the value you are replacing. ||= and ??= can be especially helpful for initializing values without accidentally overriding them later.

Numeric Separators

Until now, numbers in JavaScript had to be written as a series of digits, without any kind of separator digits allowed. This works fine for small numbers, but once you get to the millions place, it can be hard to tell what number is what. With ES2021, you can now add underscore separators anywhere in the number, ahead or behind the decimal point. This lets it work with different separation formats from different parts of the world.

const normalNum = 123456.78912;
const separatedNum = 123_456.78_9_12;
console.log(normalNum === separatedNum); // true
// Use a separator to differentiate between dollars and cents
const moneyInCents = 349_99;

Why would you use this? Because you want to be able to read numbers that have more than three digits without squinting at your screen and using your cursor to count the digits. Numeric Separators have no performance impact — they works exactly the same as regular numbers, but they're a lot easier to read 🎉.

String.prototype.replaceAll()

The String.prototype.replace() method only replaces the first occurrence of a string when you use a string as the input. Before ES2021, replacing all of the occurrences of one string in another required using a regular expression with the /g flag on the end.

const originalString = "Always give up! Always surrender!";
const replacedString = originalString.replace("Always", "Never");
console.log(replacedString); // "Never give up! Always surrender!"
// You must use the "g" global flag
const regexReplaceString = originalString.replace(/Always/g);
console.log(regexReplaceString); // "Never give up! Never surrender!"

While this works just fine, it is also a little counter-intuitive — I always expect that every string will be replaced without me needing to use a regular expression. Plus, the regular expression makes it a little harder to read.

ES2021 adds the String.prototype.replaceAll() method as a convenience to let you pass a string as the input.

const originalString = "Always give up! Always surrender!";
const allReplacedString = originalString.replaceAll("Always", "Never");
console.log(allReplacedString); // "Never give up! Never surrender!"

This method still works with regular expressions, however it requires that they use the global /g flag — otherwise it will throw an error. There are also special strings you can use inside your replacement string, such as $& which represents the matched string. I can use this to easily wrap the existing string with other strings, like adding quotes to the matched string.

const originalString = "Always give up! Always surrender!";
const allReplacedString = originalString.replaceAll("Always", '"$&"');
console.log(allReplacedString); // '"Always" give up! "Always" surrender!`

Why would you use this? String.prototype.replaceAll() makes replacing every instance of a string in some text just a little bit easier, all without needing messy regular expressions.

Promise.any()

Whenever we we need to do something asynchronous in JavaScript, we reach for the trusty Promise. These let us schedule work and provide a way to resume executing our code once the work is done. JavaScript Promises can be in one of three states — "pending", "fulfilled", or "rejected". We'll say that "fulfilled" and "rejected" are resolved states, meaning the promise is done processing.

There are a few ways to orchestrate Promises in JavaScript. Promise.all() runs an array of promises and runs them concurrently, resolving once all of the promises fulfill or rejecting when any one of them reject.

import getBlogPost from "./utils/getBlogPost";
Promise.all([getBlogPost(1), getBlogPost(3), getBlogPost(4)])
.then((blogPosts) => {
// Do something with our array of blog posts
})
.catch((error) => {
// If any of the promises rejected, the entire Promise.all call will reject
});

Promise.race() also takes an array of promises, but it fulfills or rejects as soon as any one of the promises fulfills or rejects.

import getBlogPost from "./utils/getBlogPost";
const wait = (time) => new Promise((resolve) => setTimeout(resolve, time));
Promise.race([
getBlogPost(1),
wait(1000).then(() => Promise.reject("Request timed out")),
])
.then(([blogPost]) => {
// If getBlogPost fulfilled first, we'll get it here
})
.catch((error) => {
// If the request timed out, the `Promise.reject` call
// above will cause this catch block to execute
});

Just last year we were introduced to Promise.allSettled, which runs all of the promises, regardless of whether any of them fulfill or reject. Once all of them are resolved one way or the other, it returns an array describing the results of each promise.

import updateBlogPost from "./utils/updateBlogPost";
Promise.allSettled([
updateBlogPost(1, {tags:["react","javascript"]})
updateBlogPost(3, {tags:["react","javascript"]})
updateBlogPost(7, {tags:["react","javascript"]})
]).then(results => {
// Regardless of whether any of the promises reject, all of them
// will be executed.
console.log(results);
// [
// {status: "fulfilled", value: {/* ... */}},
// {status: "fulfilled", value: {/* ... */}},
// {status: "rejected", reason: Error: 429 Too Many Requests}
// ]
})

Promise.any() is a new Promise function that works a bit like Promise.race(). You pass it a list of promises. It will resolve as soon as one of the promises is fulfilled, but it won't reject until it's done resolving all of the promises. If every single promise in the list rejects, it returns what's called an Aggregate Error, which groups together all of the errors from the promise rejections.

In this example, we'll do a little bit of web scraping to see which website loads the fastest. We want it to ignore any sites that might be offline too. If you try running this in a browser, you'll get an AggregateError, due to CORS security errors. However, if you run it in NodeJS v16+ with a fetch polyfill, like node-fetch, you'll get a response from one of the sites.

Promise.any([
fetch("https://google.com/").then(() => "google"),
fetch("https://apple.com").then(() => "apple"),
fetch("https://microsoft.com").then(() => "microsoft"),
])
.then((first) => {
// Any of the promises was fulfilled.
console.log(first);
})
.catch((error) => {
// All of the promises were rejected.
console.log(error);
});

Why would you use this? Promise.any() lets you run a list of promises concurrently, ignoring any that reject unless all of the promises reject.

WeakRef and FinalizationRegistry

JavaScript famously uses a garbage collector to manage memory. That means you don't have to de-allocate variables when you are done working with them, which is incredibly convenient. However, it does mean that if you're not careful, variables can hang out in memory for too long, causing memory leaks.

The job of the garbage collector is to keep track of the references that objects have to other objects – like global variables, variables defined in a function closure, or properties on an object. Any time you assign an existing object to another variable, another reference is created and the garbage collector takes note. These types of references are called "Strong" references. The memory for those objects will be retained until there are no more references to the object. At that point the garbage collector will remove the object and clean up the memory.

Sometimes, though, you might want to have an object be garbage collected even sooner. For example, we might want to have a cache that we want to have the garbage collector clear out more frequently, just in case that cache fills up with big objects that consume all of the browser's memory. For that, we use a WeakRef.

We can create a WeakRef with its constructor, which takes an object of some kind.

// This is a regular Object
const blogPostCache = {};
// This is a WeakRef Object.
const weakBlogPostCache = new WeakRef({});

To access values on our weakBlogPostCache, we need to use the .deref method. This lets us access the underlying object, which we can then mutate.

const blogPostRecord = {
title: "A really long blog post",
body: "This blog post takes up lots of space in memory...",
};
// We'll use spread syntax to clone this object to make a new one
blogPostCache["a-really-long-blog-post"] = { ...blogPostRecord };
weakBlogPostCache.deref()["a-really-long-blog-post"] = { ...blogPostRecord };
console.log(weakBlogPostCache.deref()); // {"a-really-long-blog-post": {title: ..., body: ...}}

At this point, there's no telling when weakBlogPostCache will be garbage collected. Each browser engine has a different schedule for running the garbage collector. Usually it will run automatically every couple of minutes, or if the amount of available memory starts getting low. If you are using Google Chrome, you can click the College Garbage icon in the Performance dev tools tab.

Once the WeakRef is garbage collected, calling .deref will return undefined. It's up to you, the developer, to handle those situations, perhaps by creating a new empty WeakRef and populating it with fresh content.

FinalizationRegistry

It's possible that checking to see whether weakBlogPostCache.deref() is undefined isn't responsive enough. If we wanted to reinitialize our empty cache the moment it was garbage collected, we would need some kind of callback from the garbage collector.

The FinalizationRegistry constructor was released alongside WeakRef to register callbacks to be called when a WeakRef is garbage collected. We can create a registry, pass it a callback, and then register our WeakRef with that registry.

Since the WeakRef's contents are gone when our callback is called, we need to pass some other value to the registry to help us know which WeakRef was garbage collected. When we register our WeakRef, we register a proxy value that is passed to the callback function. In the example below, that value is "Weak Blog Post Cache".

let weakBlogPostCache = new WeakRef({});
const registry = new FinalizationRegistry((value) => {
console.log("Value has been garbage collected:", value);
// Reinitialize our cache
weakBlogPostCache = new WeakRef({});
});
registry.register(weakRefObject, "Weak Blog Post Cache");

In the above example, once our weakBlogPostCache is garbage collected, the FinalizationRegistry will log Value has been garbage collected: Weak Blog Post Cache.

This feature is by far the most complicated of all of the features introduced; it's intended only for the most low-level use-cases, so you likely won't be messing with it unless you are writing libraries in JavaScript or applications with complicated memory requirements. Regardless, it opens up some performance optimizations that wouldn't be possible before. If you want a more in-depth explanation, including a few notes of caution, check out the full TC39 proposal.

Why would you use this? If you need to keep a cache of large objects without running out of memory, WeakRef can make the garbage collector remove those objects a little bit sooner. If you need to know exactly when one of your WeakRef objects has been removed from memory, you can use FinalizationRegistry


Like always, the TC39 committee and browser vendors have given us some excellent new APIs to make writing JavaScript a a little easier, faster, and more fun. And with 12 exciting proposals currently in Stage 3, it looks like we've got some more solid changes to look forward to in future updates.