Modern JS13 min read · Updated 2025-06-01

Modern JavaScript: ES6+ Features Every Developer Must Know

Not a changelog — a curated guide to the ES6+ features that changed how JavaScript is written, with honest explanations of what each one actually does and when to reach for it.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

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:

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.

📚 Practice These Topics
Var let const
3–5 questions
Destructuring
4–6 questions
Spread rest
3–5 questions
Arrow function
4–7 questions

Put This Into Practice

Reading articles is passive. JSPrep Pro makes you actively recall, predict output, and get AI feedback.

Start Free →Browse All Questions

Related Articles

Core Concepts
map() vs forEach() in JavaScript: Which One to Use and Why It Matters
7 min read
Core Concepts
Arrow Functions vs Regular Functions in JavaScript: 6 Key Differences
9 min read
Interview Prep
Promise.all vs allSettled vs race vs any: The Complete Comparison
9 min read