ES6 shipped in 2015 and broke JavaScript open. In the nine years since, the language has added more useful features than the previous two decades combined. Some of these you use daily. Some you've heard of but haven't needed. Some are so new they're barely in tutorials yet.
This guide covers the features worth knowing deeply — not because they'll show up on a quiz, but because they make code genuinely better.
ES6 (2015) — The Foundation
let and const
Block-scoped variable declarations. const for values that don't get reassigned. let for values that do. var for nothing in new code. The reason this matters beyond style: let and const have the Temporal Dead Zone, which turns use-before-declaration bugs from silent undefined into immediate ReferenceErrors.
// const for references that don't change
const user = { name: 'Alice' }
user.name = 'Bob' // fine — the reference didn't change, the object did
user = {} // TypeError — can't reassign the binding
// let for values that need reassignment let count = 0 count++ // fine
Arrow Functions
Shorter syntax plus lexical this — those are the two things arrow functions do. The this part is what makes them genuinely useful rather than just shorter:
class Timer {
constructor() {
this.seconds = 0
}
start() { // Arrow function: 'this' inherited from start()'s context (the Timer instance) setInterval(() => { this.seconds++ console.log(this.seconds) }, 1000)
// Regular function: 'this' is undefined in strict mode (or window in sloppy) // setInterval(function() { this.seconds++ }, 1000) // broken } }
Don't use arrow functions for object methods, prototype methods, or constructors — those need their own this.
Template Literals
String interpolation and multi-line strings. What you're already using, but it goes deeper than ${variable}:
// Tagged templates — a function that processes the template
function highlight(strings, ...values) {
return strings.reduce((result, str, i) => {
const val = values[i - 1]
return result + <mark>${val}</mark> + str
})
}
const name = 'Alice' const role = 'engineer' highlightWelcome, ${name}. Your role is ${role}. // 'Welcome, <mark>Alice</mark>. Your role is <mark>engineer</mark>.'
Tagged templates power GraphQL (gql), CSS-in-JS (css), SQL sanitization, and internationalization libraries.
Destructuring
Extract values from objects and arrays without intermediate variables:
// Object destructuring with rename, default, and nested
const { name: firstName = 'Anonymous', address: { city } = {} } = user
// Array destructuring — pattern matching by position const [first, , third, ...rest] = [1, 2, 3, 4, 5] // first = 1, third = 3, rest = [4, 5]
// Function parameter destructuring — the React component pattern function UserCard({ name, avatar, role = 'member', onClick }) { // No more props.name, props.avatar everywhere }
// Swap variables let a = 1, b = 2 ;[a, b] = [b, a] // a = 2, b = 1 — no temp variable needed
Spread and Rest
Same syntax, opposite direction:
// Spread: expand iterable into individual elements
const merged = { ...defaults, ...overrides } // object merge
const copy = [...originalArray] // shallow array copy
const sorted = [...set].sort() // Set → sorted array
// Conditional spread — the idiomatic way to conditionally add properties const config = { host: 'localhost', ...(isDev && { debug: true, verbose: true }), // only adds if isDev }
// Rest: collect remaining elements function log(level, ...messages) { // messages is a real Array messages.forEach(m => console[level](m)) }
Default Parameters
Parameters with fallback values when not provided or undefined:
function createUser(name, role = 'viewer', createdAt = new Date()) {
return { name, role, createdAt }
}
// Default values can reference earlier parameters function range(start, end, step = (end - start) / 10) { // step defaults to 10% of the range }
Note: defaults only trigger for undefined, not null. createUser('Alice', null) — role would be null, not 'viewer'.
Classes
Syntactic sugar over prototype-based inheritance. They make the pattern cleaner but don't change the underlying mechanism:
class EventEmitter {
#listeners = new Map() // private field — genuinely private
on(event, fn) { const handlers = this.#listeners.get(event) ?? [] this.#listeners.set(event, [...handlers, fn]) return this // chaining }
emit(event, ...args) { this.#listeners.get(event)?.forEach(fn => fn(...args)) }
off(event, fn) { this.#listeners.set(event, (this.#listeners.get(event) ?? []).filter(h => h !== fn) ) } }
Promises
Covered in depth elsewhere, but the ES6 introduction of Promises standardized async JavaScript across browsers and Node.js. Every async API written since 2015 returns a Promise.
Modules
// Named exports — multiple per file
export const PI = 3.14159
export function area(r) { return PI r r }
// Default export — one per file export default class Canvas { / ... / }
// Dynamic import — lazy loading const { heavyFunction } = await import('./heavy-module.js')
// Re-export — barrel files export { area, PI } from './math.js' export { default as Canvas } from './canvas.js'
ES2017 — async/await
The single most impactful addition to JavaScript since Promises. Async functions read like synchronous code, eliminate .then() chains, and use try/catch for error handling. Covered in depth in the Promises & Async/Await guide.
ES2018 — Rest/Spread for Objects, Promise.finally
Object rest/spread reached the spec:
const { password, ...safeUser } = user // omit sensitive fields
const updated = { ...original, ...changes } // immutable update pattern
Promise.finally() runs cleanup whether a Promise resolves or rejects:
fetchData()
.then(render)
.catch(handleError)
.finally(() => setLoading(false)) // always runs
ES2019 — flat, flatMap, Object.fromEntries
Array.flat() and Array.flatMap() are more useful than they look:
// flatMap: map then flatten one level — single pass, more efficient
const sentences = ['Hello world', 'Foo bar']
sentences.flatMap(s => s.split(' ')) // ['Hello', 'world', 'Foo', 'bar']
// Object.fromEntries — the inverse of Object.entries // Transform object values in one expression: const prices = { apple: 1.5, banana: 0.75, cherry: 3.0 } const discounted = Object.fromEntries( Object.entries(prices).map(([key, val]) => [key, val * 0.9]) ) // { apple: 1.35, banana: 0.675, cherry: 2.7 }
ES2020 — Optional Chaining, Nullish Coalescing, Promise.allSettled, BigInt
Optional chaining (?.) and nullish coalescing (??) are the two ES2020 features with the highest daily usage:
// Optional chaining: short-circuits at null/undefined, returns undefined
const city = user?.address?.city // no error if address is missing
const len = data?.items?.length ?? 0 // length, or 0 if any step is null
// ?? vs ||: nullish only triggers on null/undefined, not all falsy values const port = config.port ?? 3000 // uses 3000 only if port is null/undefined const port2 = config.port || 3000 // uses 3000 if port is 0, '', false too // // 0 is a valid port — use ?? here
Promise.allSettled — run multiple Promises, get all outcomes regardless of failures:
const results = await Promise.allSettled([
fetchUserData(), fetchSettings(), fetchNotifications()
])
// Never rejects. Each result: { status: 'fulfilled', value } or { status: 'rejected', reason }
BigInt — integers beyond Number.MAX_SAFE_INTEGER (2^53 - 1):
const big = 9007199254740993n // BigInt literal
const sum = big + 1n // 9007199254740994n — precise
// Regular numbers lose precision at this magnitude
9007199254740993 === 9007199254740992 // true (!) — floating point limitation
ES2021 — Promise.any, Logical Assignment, WeakRef
Logical assignment operators — only assign if the left side meets a condition:
// These are common patterns made concise:
a ||= b // a = a || b — assign b if a is falsy
a &&= b // a = a && b — assign b if a is truthy
a ??= b // a = a ?? b — assign b only if a is null/undefined
// Practical use: user.preferences ??= {} // initialize if missing cache[key] ??= computeValue(key) // memoization in one line
String.replaceAll — replace all occurrences without regex:
'a-b-c-d'.replaceAll('-', '_') // 'a_b_c_d'
// Previously: 'a-b-c-d'.replace(/-/g, '_')
ES2022 — Private Class Fields, at(), Object.hasOwn, Top-Level Await
Private class fields (#) are language-enforced privacy — not just convention:
class SecureStore {
#data = new Map() // truly private — no external access, ever
set(key, value) { this.#data.set(key, value) } get(key) { return this.#data.get(key) }
// Check if an object has a private field (brand checking): static isSecureStore(obj) { return #data in obj } }
const store = new SecureStore() store.#data // SyntaxError — not just runtime, caught at parse time
Array.at() — index from the end without .length - 1:
const arr = [1, 2, 3, 4, 5]
arr.at(0) // 1
arr.at(-1) // 5 — last element
arr.at(-2) // 4 — second to last
Object.hasOwn — safer than obj.hasOwnProperty(), works on null-prototype objects:
Object.hasOwn(obj, 'key') // preferred
obj.hasOwnProperty('key') // breaks on Object.create(null) objects
ES2023 — Array Non-Mutating Methods
Four new methods that return new arrays instead of modifying the original — built for immutable patterns:
const original = [3, 1, 4, 1, 5]
// Previously you had to spread-then-sort: const sorted = [...original].sort((a, b) => a - b)
// Now: const sorted2 = original.toSorted((a, b) => a - b) // new array const reversed = original.toReversed() // new array const spliced = original.toSpliced(1, 2, 99) // new array const updated = original.with(2, 99) // replace index 2
original // [3, 1, 4, 1, 5] — unchanged in all cases
These are especially important in React state updates where mutation is forbidden.
ES2024 — Object.groupBy, Promise.withResolvers
Object.groupBy — group array elements without a reduce:
const products = [
{ name: 'Apple', category: 'fruit' },
{ name: 'Carrot', category: 'vegetable' },
{ name: 'Banana', category: 'fruit' },
]
const grouped = Object.groupBy(products, p => p.category) // { // fruit: [{ name: 'Apple', ... }, { name: 'Banana', ... }], // vegetable: [{ name: 'Carrot', ... }] // }
Promise.withResolvers — extract resolve and reject from a Promise without the constructor dance:
// Old pattern:
let resolve, reject
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
// New: const { promise, resolve, reject } = Promise.withResolvers() // Useful for event-to-promise conversion, deferred patterns
The Adoption Rule
Not all environments support the newest features. Check your targets:
- Production web apps: match your Browserslist config; Babel/TypeScript transpile the gaps
- Node.js: Node 20+ supports ES2023+ natively; check the Node.js compatibility table
- npm libraries: target the lowest reasonable Node/browser version; ship untranspiled ESM
The features most likely to need transpilation today: ES2024 groupBy, top-level await (needs module context), and private class fields in older targets.
Everything from ES6 through ES2020 is safe in any modern target without transpilation.