Modern JavaScript: ES2015 to today
Learn what each yearly JavaScript edition added from ES2015 to ES2026, feature by feature, with runnable examples of the ones you will use most.
- 01Place a feature in timeSay roughly which edition added a feature such as
?.,toSortedorObject.groupBy. - 02Use the everyday onesWrite code with destructuring, async functions,
?.and??, private fields and the newest Set methods. - 03Modernize old codeRewrite ES5-style code with modern features, and check that it prints the same result.
Modern JavaScript: a new edition every year
"Modern JavaScript" usually means the language from ES2015 on. ES2015 was one big edition. Since then, a smaller edition has come out every June, each with a handful of finished proposals. This lesson walks through them, year by year, with the features you will use most.
const order = { item: "tea", prices: [5, 3, 4] };
const { item, prices } = order;
console.log(`${item}: ${prices.toSorted((a, b) => a - b).join(", ")}`);
console.log(order.customer?.name ?? "guest");Line 1 makes an order. Line 2 uses destructuring, from ES2015, to copy item and prices out of the order into two variables.
Line 3 uses a template literal, also ES2015, and toSorted, from ES2023. It prints tea: 3, 4, 5. Line 4 uses ?. and ??, from ES2020. The order has no customer, so it prints guest.
Read each row of the table as: "this edition added these main features." The list comes from TC39's own list of finished proposals.
| The edition | Its main features |
|---|---|
| ES2015 | let and const, arrow functions, classes, modules, promises, template literals, destructuring, Map and Set, for…of, generators. |
| ES2016 | The ** operator and Array.prototype.includes. |
| ES2017 | Async functions, Object.values and Object.entries, padStart and padEnd. |
| ES2018 | Object rest and spread, async iteration, Promise.prototype.finally, named groups in regular expressions. |
| ES2019 | flat and flatMap, Object.fromEntries, trimStart and trimEnd, catch without a name. |
| ES2020 | ?., ??, BigInt, globalThis, Promise.allSettled and import(). |
| ES2021 | replaceAll, Promise.any, ??= and the other logical assignments, 1_000 number separators. |
| ES2022 | Class fields and private # names, top-level await, at, Object.hasOwn, error causes. |
| ES2023 | toSorted, toReversed, with and findLast for arrays. |
| ES2024 | Object.groupBy and Map.groupBy, Promise.withResolvers, the regex v flag. |
| ES2025 | Iterator helpers, new Set methods, Promise.try, RegExp.escape, JSON modules. |
| ES2026 | Map.prototype.getOrInsert, Math.sumPrecise, Error.isError, Array.fromAsync, base64 for Uint8Array. |
Picture a phone that went years without an update, then got one huge update that changed the home screen, the apps and the settings. After that, it gets a small update every year: a new button here, a faster menu there. Your old apps still open after each update.
- In real life: One huge update that changed almost everything
- In JavaScript: ES2015
- In real life: A small update every year after that
- In JavaScript: ES2016, ES2017 and onward
- In real life: Old apps still open after every update
- In JavaScript: Old code still runs in every new edition
ES2015 laid the foundation: let, const, classes, modules, promises and arrow functions. Every edition since adds a few smaller tools, such as async functions, ?., private fields and new array and Set methods, and all of them run next to older code.
ES2015: the big foundation
ES2015, also called ES6, added more than any edition before it. Most modern code uses its features on every line.
const prices = [3, 4, 5];
let total = 0;
for (const price of prices) {
total += price;
}
const [first, ...rest] = prices;
const label = (name, cost = 1) => `${name} costs ${cost}`;
console.log(total, first, rest.length);
console.log(label("tea", total), label("cake"));Line 1 uses const, a variable that can't be given a new value. Line 2 uses let, which can. Lines 3 to 5 loop over the prices with for…of, which visits each value.
Line 6 unpacks the array: first gets 3, and ...rest collects the other items. Line 7 makes an arrow function with a default value: if no cost is passed, cost is 1, and the template literal puts both values into one string.
Line 8 prints 12 3 2. Line 9 prints tea costs 12 cake costs 1: the second call has no cost, so it uses the default.
prices. first gets the first item, 3, and ...rest collects what is left into a new array, [4, 5].ES2015 also added modules: files that share values with export and import. This example has a second file, prices.js, which exports tax and withTax.
import { tax, withTax } from "./prices.js";
console.log(tax);
console.log(withTax(3));Line 1 imports two names from prices.js. Line 2 prints 1, and line 3 prints 4, the price plus tax. Press Edit & run to see both files.
const menu = new Map([["tea", 3], ["cake", 5]]);
const seen = new Set(["tea", "tea", "cake"]);
console.log(menu.get("cake"), seen.size);
const ready = new Promise((resolve) => setTimeout(() => resolve("tea"), 10));
ready.then((drink) => console.log(drink + " is ready"));
console.log("ordered");Line 1 makes a Map, a list of keys and values. Line 2 makes a Set, which keeps each value once, so its size is 2. Line 3 prints 5 2.
Line 4 makes a promise, a value that will be ready later: it gives "tea" after 10 milliseconds. Line 5 says what to do when it is ready. Line 6 runs first and prints ordered, then the promise prints tea is ready.
ES2016 to ES2020: async code and safer access
The next four editions each added a few practical tools.
console.log(2 ** 3);
console.log(["tea", "cake"].includes("cake"));
console.log(Object.entries({ tea: 3, cake: 4 }).join(" | "));
console.log("7".padStart(3, "0"));
const order = { item: "tea", price: 3 };
const copy = { ...order, price: 4 };
console.log(copy.price, order.price);
console.log([3, [4, [5]]].flat(Infinity).join(" "));Lines 1 and 2 are from ES2016: 2 ** 3 is 2 to the power of 3, which prints 8, and includes prints true. Lines 3 and 4 are from ES2017: Object.entries turns an object into pairs, and padStart prints 007.
Line 6 is from ES2018: ...order copies the order's properties into a new object, and price: 4 replaces one. Line 7 prints 4 3, because the original is unchanged. Line 8 is from ES2019: flat(Infinity) prints 3 4 5.
ES2017 also added the biggest change of these years: async functions. Inside one, await pauses the function until a promise is ready, without stopping the rest of the page.
function wait(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function brew() {
const tea = await wait(10, "tea");
const cake = await wait(10, "cake");
return tea + " and " + cake;
}
brew().then((result) => console.log(result));
console.log("waiting");Lines 1 to 3 make wait, which gives a value after some milliseconds. Lines 4 to 8 make brew, an async function. Line 5 waits for the tea, and line 6 waits for the cake.
Line 9 starts brew. While it waits, line 10 runs and prints waiting. About 20 milliseconds later, brew finishes and prints tea and cake.
ES2020 added two operators that make reading data safer: ?., optional chaining, and ??, the nullish coalescing operator.
const order = { item: "tea", customer: null, discount: 0 };
console.log(order.customer?.name);
console.log(order.customer?.name ?? "guest");
console.log(order.discount || 10, order.discount ?? 10);
console.log(2n ** 64n);Line 2 reads order.customer?.name. The customer is null, so ?. stops and gives undefined instead of throwing. Line 3 adds ?? "guest", which uses "guest" when the left side is null or undefined.
Line 4 shows the difference from ||. The discount is 0. || treats 0 as false and gives 10, but ?? keeps 0, so it prints 10 0. Line 5 uses BigInt, whole numbers of any size, and prints 18446744073709551616n.
order.customer is null, so ?. stops and gives undefined instead of throwing. ?? sees undefined and uses "guest".const order = { item: "tea", customer: null };
const name = order.customer?.name ?? "guest";
console.log(name);
const other = { customer: { name: "Asha" } };
console.log(other.customer?.name ?? "guest");Line 2 stores "guest", because the order has no customer, and line 3 prints it. Line 5 reads a real customer, so ?? keeps the name and it prints Asha.
A replay of the example code, not a view inside a JavaScript engine. The example runs for real.
script
const name = order.customer?.name ?? "guest";console.log(name);const other = { customer: { name: "Asha" } };console.log(other.customer?.name ?? "guest");Press Step to see ?. stop at null on line 2, and ?? choose. This replays the example code; it isn't a view inside the JavaScript engine.
ES2021 to ES2024: smaller, sharper tools
These editions filled gaps that people had worked around with helpers for years.
const prices = [5, 3, 4];
console.log("tea-tea".replaceAll("tea", "cake"));
let stock = null;
stock ??= 10;
console.log(stock, 1_000_000);
console.log(prices.at(-1), Object.hasOwn({ tea: 3 }, "tea"));
const sorted = prices.toSorted((a, b) => a - b);
console.log(sorted.join(" ") + " / " + prices.join(" "));
console.log(prices.findLast((p) => p > 3));Line 2 uses replaceAll and prints cake-cake. Lines 3 and 4 use ??=, which gives stock a value only if it is null or undefined. Line 5 prints 10 1000000: the underscores in 1_000_000 are only for reading. All of these are from ES2021.
Line 6 is from ES2022: at(-1) reads the last item, 4, and Object.hasOwn checks for an own property, so it prints 4 true.
Lines 7 to 9 are from ES2023. toSorted makes a sorted copy and leaves the original alone, so line 8 prints 3 4 5 / 5 3 4. Line 9's findLast searches from the end and prints 4.
ES2022 also finished class features: fields written straight in the class body, and private fields, whose names start with # and can be used only inside the class.
class Cart {
#items = [];
static count = 0;
add(item) {
this.#items.push(item);
Cart.count += 1;
return this;
}
get size() {
return this.#items.length;
}
}
const cart = new Cart().add("tea").add("cake");
console.log(cart.size, Cart.count, cart.items);Line 2 makes a private field, #items. Line 3 makes a static field, which belongs to the class itself, not to each cart. Lines 4 to 8 add an item and count it, and line 7 returns the cart so calls can be chained, as on line 13.
Line 14 prints 2 2 undefined. The getter on lines 9 to 11 can read #items, but cart.items finds nothing, because private fields aren't normal properties.
const menu = [
{ name: "tea", kind: "drink" },
{ name: "cake", kind: "food" },
{ name: "juice", kind: "drink" },
];
const groups = Object.groupBy(menu, (item) => item.kind);
console.log(groups.drink.map((item) => item.name).join(", "));
const { promise, resolve } = Promise.withResolvers();
promise.then((value) => console.log("ready: " + value));
resolve("tea");Line 6, from ES2024, groups the menu by kind. Line 7 prints the drinks: tea, juice.
Line 8 uses Promise.withResolvers, also ES2024, which gives a promise together with the function that completes it. Line 10 calls resolve, so the promise prints ready: tea.
ES2025 and ES2026: the newest editions
ES2025 gave Set the methods people had wanted for years, and added iterator helpers: map, filter, take and more, for any iterator, not just arrays.
const tea = new Set(["mint", "lemon", "ginger"]);
const cake = new Set(["lemon", "chocolate"]);
console.log([...tea.intersection(cake)].join(", "));
console.log(tea.union(cake).size);
const doubled = [3, 4, 5, 6].values().map((p) => p * 2).take(2).toArray();
console.log(doubled.join(" "));Lines 1 and 2 make two sets of flavors. Line 3 prints the flavor in both, lemon. Line 4 prints 4, the number of flavors in either.
Line 5 asks the array for an iterator with values(), doubles each item, takes the first two, and turns them into an array. Line 6 prints 6 8.
Unlike array methods, iterator helpers are lazy: they don't touch an item until something asks for it.
const prices = [3, 4, 5, 6];
const checked = [];
const first = prices
.values()
.filter((p) => {
checked.push(p);
return p < 5;
})
.map((p) => p * 2)
.take(1)
.toArray();
console.log(first.join(" "), "checked:", checked.join(" "));Lines 3 to 11 describe the steps: filter the prices under 5, double them, and take one. Line 6 records every price that filter checks.
Line 12 prints 6 checked: 3. The first price, 3, passed, and take(1) needed only one, so 4, 5 and 6 were never checked. With an array's filter, all four would have been checked.
filter, map doubles it to 6, and take(1) has all it needs. So 4, 5 and 6 are never checked, and line 12 prints 6 checked: 3.A replay of the example code, not a view inside a JavaScript engine. The iterator helpers run for real, so this needs a browser from 2025 or later.
script
const checked = [];const first = prices .values() .filter((p) => { checked.push(p); return p < 5; }) .map((p) => p * 2) .take(1) .toArray();console.log(first.join(" "), "checked:", checked.join(" "));Press Step and watch the checked list beside line 2. It ends with just one price. This replays the example code; it isn't a view inside the JavaScript engine.
const price = "1.5$";
console.log(new RegExp(price).test("1x5"));
console.log(new RegExp(RegExp.escape(price)).test("1x5"));
console.log(new RegExp(RegExp.escape(price)).test("costs 1.5$"));
const parsed = Promise.try(() => JSON.parse("{bad"));
console.log(parsed instanceof Promise);
parsed.catch((error) => console.log(error.name));Line 2 builds a regex straight from the text 1.5$. In a regex, . means any character, so it matches 1x5 by mistake and prints true. RegExp.escape makes every special character plain, so line 3 prints false and line 4 prints true.
Line 5 uses Promise.try. The function throws, but instead of a thrown error you get a promise that has failed. Line 6 prints true, and line 7 then prints SyntaxError.
ES2026, the newest edition, came out in June 2026.
const stock = new Map();
stock.getOrInsert("tea", 0);
stock.set("tea", stock.get("tea") + 3);
console.log(stock.getOrInsert("tea", 0));
console.log(Math.sumPrecise([0.1, 0.2, 0.3]), 0.1 + 0.2 + 0.3);
console.log(Error.isError(new TypeError("no tea")), Error.isError({ message: "fake" }));
console.log(new Uint8Array([116, 101, 97]).toBase64());Line 2 uses getOrInsert: tea isn't in the map, so it stores 0. Line 3 adds 3. On line 4, tea is already there, so getOrInsert just gives it back, and it prints 3.
Line 5 prints 0.6 0.6000000000000001: Math.sumPrecise adds the numbers without the tiny rounding error of +. Line 6 prints true false, because only a real error object passes Error.isError. Line 7 turns three bytes into base64 and prints dGVh.
For what is coming after ES2026, the how JavaScript evolves lesson shows where to follow proposals.
Rewriting old code with modern features
Most codebases mix old and new code. Modern features don't make old code wrong. They let you write the same thing in fewer words, with fewer chances for mistakes. Change old code when you are already working on it, and check that it still gives the same result.
The playground runs the old way and the modern way of doing the same job. Pick a feature, guess whether both print the same thing, then read the two programs line by line.
const order = { item: "tea", price: 3, customer: null };
console.log("A " + order.item + " costs " + order.price + ".");const order = { item: "tea", price: 3, customer: null };
console.log(`A ${order.item} costs ${order.price}.`);sameA tea costs 3.A tea costs 3.Both programs print the same thing. The template literal version, from ES2015, says it in fewer words.
Every pair prints the same result. Look at toSorted: the old way needs slice() first to avoid changing the original. And ?. and ?? replace two checks with two short operators.
Before you use a newer feature, check that the browsers your users have support it. The Baseline section of the previous lesson explains how.
(p) => p * 2class Cart {}new Promise(…)async function brew() {}order.customer?.name2 ** 3"a-a".replaceAll("a", "b")#items = []in a classprices.toSorted()Object.groupBy(menu, fn)tea.intersection(cake)[3, 4].values().map(fn)stock.getOrInsert("tea", 0)
Put each feature with the editions that added it: ES2015, ES2016 to ES2020, ES2021 to ES2024, or ES2025 and ES2026.
Common mix-ups
Most mix-ups come from features that look alike but behave differently.
- "
??is the same as||." No.??falls back only fornullandundefined.||also falls back for 0,""andfalse. - "
toSortedsorts the array." No. It returns a sorted copy.sortchanges the array itself. - "
constmakes a value unchangeable." No. It stops the variable getting a new value. An object in aconstcan still change. - "
awaitstops the whole page." No. It pauses only its own async function. Other code keeps running. - "Private fields are just properties starting with #." No. They are not properties at all, and code outside the class can't reach them.
- "Iterator helpers work like array methods." No. They are lazy, and handle one item at a time, only when asked.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| The feature | What people think | What is true |
|---|---|---|
?? | It works like ||. | It falls back only for null and undefined, so 0 and "" are kept. |
toSorted | It sorts the array. | It returns a sorted copy. The original array stays as it was. |
| ES2015 features | They are too new for real projects. | Every current browser has supported them for years. |
Practice exercises
For each program, find the modern feature and ask what it does differently from the older way. Guess first, then run it.
What does line 2 print?
const order = { item: "tea" };
console.log(order.customer?.name ?? "guest", 0 ?? 5, 0 || 5);const order = { item: "tea" };
console.log(order.customer?.name ?? "guest", 0 ?? 5, 0 || 5);order.customer?.name is undefined, so ?? gives guest. 0 ?? 5 keeps 0, because 0 is not null or undefined. 0 || 5 gives 5, because 0 is falsy. Line 2 prints guest 0 5.
What does line 2 print?
const [first, , third = 9, ...rest] = [3, 4];
console.log(first, third, rest.length);const [first, , third = 9, ...rest] = [3, 4];
console.log(first, third, rest.length);first gets 3, the 4 is skipped, third has nothing so it uses 9, and rest is empty. Line 2 prints 3 9 0.
What does line 3 print? Then fix line 2 so prices keeps its original order.
const prices = [5, 3, 4];
const sorted = prices.sort((a, b) => a - b);
console.log(prices.join(" ") + " / " + sorted.join(" "));const prices = [5, 3, 4];
const sorted = prices.toSorted((a, b) => a - b);
console.log(prices.join(" ") + " / " + sorted.join(" "));sort reorders prices and returns the same array, so both sides print 3 4 5. With toSorted, prices keeps 5 3 4, and the program prints 5 3 4 / 3 4 5.
What does the program print, in order?
async function brew() {
console.log("start");
await null;
console.log("after await");
}
brew();
console.log("outside");async function brew() {
console.log("start");
await null;
console.log("after await");
}
brew();
console.log("outside");brew prints start, then pauses at await null. Line 7 prints outside. Then brew carries on and prints after await. So the order is start, outside, after await.
Rewrite the program with const, filter, map, destructuring and a template literal. What does it print?
var orders = [{ item: "tea", price: 3 }, { item: "cake", price: 5 }, { item: "juice", price: 4 }];
var names = [];
for (var i = 0; i < orders.length; i++) {
if (orders[i].price < 5) names.push(orders[i].item.toUpperCase());
}
console.log("Cheap: " + names.join(", "));const orders = [{ item: "tea", price: 3 }, { item: "cake", price: 5 }, { item: "juice", price: 4 }];
const names = orders
.filter(({ price }) => price < 5)
.map(({ item }) => item.toUpperCase());
console.log(`Cheap: ${names.join(", ")}`);filter keeps tea and juice, map makes them capital letters, and the template literal builds the line. It prints Cheap: TEA, JUICE, like the original.
Replace lines 3 and 4 with ES2025 Set methods. What does your program print?
const tea = new Set(["mint", "lemon", "ginger"]);
const cake = new Set(["lemon", "chocolate"]);
const both = [...tea].filter((flavor) => cake.has(flavor));
const one = [...tea].filter((flavor) => !cake.has(flavor)).concat([...cake].filter((flavor) => !tea.has(flavor)));
console.log("both: " + both.join(", ") + " / one: " + one.join(", "));const tea = new Set(["mint", "lemon", "ginger"]);
const cake = new Set(["lemon", "chocolate"]);
const both = [...tea.intersection(cake)];
const one = [...tea.symmetricDifference(cake)];
console.log("both: " + both.join(", ") + " / one: " + one.join(", "));intersection gives lemon. symmetricDifference gives mint and ginger from the first set, then chocolate from the second. It prints the same line as the long version.
Check your understanding
For each question, ask which edition the feature came from, and what it does differently from the older way. Each answer explains why it's right or wrong.
Question 1 of 7Which edition added the most since ES5?
Choose an answer to see the explanation.
Question 2 of 7What do ?? and || give for 0?
Read the code, then predictconsole.log(0 ?? 5, 0 || 5);Choose an answer to see the explanation.
Question 3 of 7What does toSorted leave in the original array?
Read the code, then predictconst prices = [5, 3, 4]; prices.toSorted((a, b) => a - b); console.log(prices.join(" "));Choose an answer to see the explanation.
Question 4 of 7Which edition brought async functions and await?
Choose an answer to see the explanation.
Question 5 of 7Which prices does a lazy filter check before take(1) stops it?
Read the code, then predictconst checked = []; [3, 4, 5].values().filter((p) => { checked.push(p); return p > 3; }).take(1).toArray(); console.log(checked.join(" "));Choose an answer to see the explanation.
Question 6 of 7What does reading a private field by its name print?
Read the code, then predictclass Cup { #size = 3; get size() { return this.#size; } } const cup = new Cup(); console.log(cup.size, cup.size === cup["#size"]);Choose an answer to see the explanation.
Question 7 of 7Which ES2025 method finds the items two sets have in common?
Choose an answer to see the explanation.
Key takeaways
Modern JavaScript is the big ES2015 foundation plus a small set of tools each year. Knowing which year a feature came from helps you judge whether your users' browsers have it.
- ES2015 added
let,const, arrow functions, classes, modules, promises, destructuring and template literals. - ES2017 added
asyncandawait. ES2020 added?.and??. ??falls back only fornullandundefined;||falls back for any falsy value.- ES2022 added private
#fields. ES2023 addedtoSorted, which leaves the original array alone. - ES2024 added
Object.groupBy. ES2025 added Set methods and lazy iterator helpers. - ES2026 added
getOrInsert,Math.sumPreciseandError.isError. - Modernize code when you are working on it, and check it still prints the same result.
Remember the one-liner.
ES2015 built the foundation, and each June since adds a few sharper tools that work next to all the old ones.
Coming next: On the horizon. It looks at proposals worth watching, such as decorators and pattern matching, and how to try them today.