Skip to content

Modern ES6+ Features

💡
Before you start

You need Node installed, and to know how to run a .js file. If node --version in a terminal prints a version number, you are ready. If it does not — or if you have never opened a terminal — do Getting Started with JavaScript first; it installs Node and runs your first program in about fifteen minutes. Nothing else is needed.

let and const (Recap)

ES6 introduced let and const as replacements for var. If you have been following the previous tutorials, you are already using them. Here is a quick summary of why they matter.

// var — function-scoped, hoisted, can be redeclared (avoid)
var x = 1;
var x = 2; // No error — this is confusing

// let — block-scoped, can be reassigned, cannot be redeclared
let y = 1;
y = 2;     // OK — reassignment is fine
// let y = 3; // Error! Cannot redeclare in same scope

// const — block-scoped, cannot be reassigned
const z = 1;
// z = 2; // Error! Cannot reassign

// const with objects/arrays — the reference is constant, not the contents
const user = { name: "Alice" };
user.name = "Bob"; // OK — modifying properties is fine
// user = {};      // Error! Cannot reassign the variable

const numbers = [1, 2, 3];
numbers.push(4);   // OK — modifying the array is fine
// numbers = [];   // Error! Cannot reassign

Rule of thumb: use const by default. Switch to let only when you need to reassign. Never use var.

Arrow Functions (Recap)

Arrow functions provide a shorter syntax for writing functions. They are especially useful for callbacks and inline functions.

// Traditional function
function add(a, b) {
    return a + b;
}

// Arrow function
const add = (a, b) => {
    return a + b;
};

// Short form — implicit return (no curly braces, no return keyword)
const add = (a, b) => a + b;

// Single parameter — parentheses are optional
const double = n => n * 2;

// No parameters — empty parentheses required
const greet = () => "Hello!";

// Returning an object — wrap in parentheses
const makeUser = (name) => ({ name: name, role: "user" });

// Perfect for callbacks
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);           // [2, 4, 6, 8, 10]
const evens = numbers.filter(n => n % 2 === 0);    // [2, 4]
const sum = numbers.reduce((acc, n) => acc + n, 0); // 15

Template Literals

Template literals use backticks instead of quotes and support string interpolation and multi-line strings.

const name = "Alice";
const age = 30;

// Old way — string concatenation
const message = "Hello, " + name + "! You are " + age + " years old.";

// Template literal — much cleaner
const message = `Hello, ${name}! You are ${age} years old.`;

// Expressions inside ${}
const price = 19.99;
const tax = 0.08;
console.log(`Total: $${(price * (1 + tax)).toFixed(2)}`); // Total: $21.59

// Multi-line strings
const html = `
    <div class="card">
        <h2>${name}</h2>
        <p>Age: ${age}</p>
    </div>
`;

// Conditional expressions
const status = `User is ${age >= 18 ? "an adult" : "a minor"}`;

// Function calls inside templates
const items = ["apple", "banana", "cherry"];
console.log(`Items: ${items.join(", ")}`); // Items: apple, banana, cherry

Tagged Templates

// Tagged templates let you process template literals with a function
function highlight(strings, ...values) {
    return strings.reduce((result, str, i) => {
        const value = values[i] !== undefined ? `<strong>${values[i]}</strong>` : "";
        return result + str + value;
    }, "");
}

const name = "Alice";
const role = "admin";
const html = highlight`User ${name} has ${role} access`;
// "User <strong>Alice</strong> has <strong>admin</strong> access"

Destructuring

Destructuring lets you unpack values from arrays or properties from objects into distinct variables in a single statement.

Object Destructuring

const user = {
    name: "Alice",
    age: 30,
    email: "alice@example.com",
    address: {
        city: "New York",
        country: "USA"
    }
};

// Without destructuring
const name = user.name;
const age = user.age;

// With destructuring
const { name, age, email } = user;
console.log(name);  // "Alice"
console.log(age);   // 30
console.log(email); // "alice@example.com"

// Rename variables
const { name: userName, email: userEmail } = user;
console.log(userName);  // "Alice"

// Default values
const { name, role = "user" } = user;
console.log(role); // "user" (doesn't exist on object, uses default)

// Nested destructuring
const { address: { city, country } } = user;
console.log(city);    // "New York"
console.log(country); // "USA"

// In function parameters
function greet({ name, age }) {
    console.log(`Hello ${name}, you are ${age}`);
}
greet(user); // Hello Alice, you are 30

Array Destructuring

const colors = ["red", "green", "blue", "yellow"];

// Basic destructuring
const [first, second, third] = colors;
console.log(first);  // "red"
console.log(second); // "green"

// Skip elements
const [, , thirdColor] = colors;
console.log(thirdColor); // "blue"

// Rest element (collect remaining items)
const [primary, ...others] = colors;
console.log(primary); // "red"
console.log(others);  // ["green", "blue", "yellow"]

// Default values
const [a, b, c, d, e = "purple"] = colors;
console.log(e); // "purple"

// Swap variables without a temp variable
let x = 1;
let y = 2;
[x, y] = [y, x];
console.log(x); // 2
console.log(y); // 1

// Destructuring function return values
function getCoordinates() {
    return [40.7128, -74.0060];
}
const [lat, lng] = getCoordinates();

Spread and Rest Operators

The ... syntax serves two purposes: spreading (expanding) and resting (collecting).

Spread Operator (Expanding)

// Spread arrays
const fruits = ["apple", "banana"];
const moreFruits = ["cherry", ...fruits, "date"];
// ["cherry", "apple", "banana", "date"]

// Copy an array (shallow)
const original = [1, 2, 3];
const copy = [...original];
copy.push(4);
console.log(original); // [1, 2, 3] — unchanged

// Merge arrays
const all = [...fruits, ...moreFruits];

// Spread objects
const defaults = { theme: "dark", language: "en", fontSize: 14 };
const userPrefs = { language: "es", fontSize: 16 };
const settings = { ...defaults, ...userPrefs };
// { theme: "dark", language: "es", fontSize: 16 }
// Later properties override earlier ones

// Copy an object (shallow)
const userCopy = { ...user };

// Add/override properties
const updatedUser = { ...user, age: 31, role: "admin" };

// Spread into function arguments
const numbers = [5, 3, 8, 1, 9];
const max = Math.max(...numbers); // 9

Rest Operator (Collecting)

// Rest in function parameters — collect all arguments
function sum(...numbers) {
    return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3));     // 6
console.log(sum(1, 2, 3, 4));  // 10

// Rest with other parameters
function logFirst(first, ...rest) {
    console.log("First:", first);
    console.log("Rest:", rest);
}
logFirst("a", "b", "c"); // First: a, Rest: ["b", "c"]

// Rest in object destructuring
const { name, ...otherProps } = user;
console.log(name);       // "Alice"
console.log(otherProps); // { age: 30, email: "...", address: {...} }

// Rest in array destructuring
const [head, ...tail] = [1, 2, 3, 4, 5];
console.log(head); // 1
console.log(tail); // [2, 3, 4, 5]

Default Parameters

Functions can have default values for parameters. If the caller does not provide an argument (or passes undefined), the default is used.

// Basic defaults
function greet(name = "World") {
    return `Hello, ${name}!`;
}
console.log(greet());         // "Hello, World!"
console.log(greet("Alice"));  // "Hello, Alice!"

// Defaults can use other parameters
function createUser(name, role = "user", id = Date.now()) {
    return { name, role, id };
}

// Defaults with destructuring
function configure({ theme = "dark", language = "en", debug = false } = {}) {
    console.log(`Theme: ${theme}, Language: ${language}, Debug: ${debug}`);
}
configure();                        // Uses all defaults
configure({ theme: "light" });     // Override just one
configure({ debug: true });        // Override just one

// Defaults with expressions
function fetchData(url, timeout = 5000, retries = 3) {
    console.log(`Fetching ${url} (timeout: ${timeout}ms, retries: ${retries})`);
}
fetchData("/api/users");                     // timeout: 5000, retries: 3
fetchData("/api/users", 10000);              // timeout: 10000, retries: 3
fetchData("/api/users", undefined, 1);       // timeout: 5000, retries: 1

Optional Chaining (?.)

Optional chaining lets you safely access deeply nested properties without checking each level for null or undefined.

const user = {
    name: "Alice",
    address: {
        city: "New York"
    }
};

// Without optional chaining — verbose null checks
const zipCode = user && user.address && user.address.zipCode;

// With optional chaining — clean and safe
const zipCode = user?.address?.zipCode;
console.log(zipCode); // undefined (no error thrown)

// Without it, this would throw: "Cannot read property of undefined"
const street = user?.address?.street?.name; // undefined, not an error

// Works with methods
const result = user?.getProfile?.();       // undefined if getProfile doesn't exist

// Works with arrays
const firstOrder = user?.orders?.[0];       // undefined if orders doesn't exist

// Works with function calls
const callback = undefined;
callback?.();  // Does nothing instead of throwing an error

// Practical example
function displayCity(user) {
    const city = user?.address?.city ?? "Unknown";
    console.log(`City: ${city}`);
}
displayCity({ name: "Alice", address: { city: "NYC" } }); // City: NYC
displayCity({ name: "Bob" });                              // City: Unknown
displayCity(null);                                         // City: Unknown

Nullish Coalescing (??)

The nullish coalescing operator ?? returns the right-hand value only when the left-hand value is null or undefined. This is different from ||, which also treats 0, "", and false as falsy.

// || treats 0, "", and false as falsy
const count = 0;
console.log(count || 10);  // 10 (0 is falsy!)
console.log(count ?? 10);  // 0  (0 is NOT null/undefined)

const name = "";
console.log(name || "Anonymous");  // "Anonymous" ("" is falsy!)
console.log(name ?? "Anonymous");  // ""  ("" is NOT null/undefined)

const debug = false;
console.log(debug || true);   // true (false is falsy!)
console.log(debug ?? true);   // false (false is NOT null/undefined)

// Only null and undefined trigger the fallback with ??
console.log(null ?? "default");      // "default"
console.log(undefined ?? "default"); // "default"
console.log(0 ?? "default");         // 0
console.log("" ?? "default");        // ""
console.log(false ?? "default");     // false

// Practical: setting config with safe defaults
function configure(options) {
    const timeout = options.timeout ?? 5000;   // 0 is a valid timeout
    const retries = options.retries ?? 3;      // 0 retries means "don't retry"
    const verbose = options.verbose ?? false;
    return { timeout, retries, verbose };
}

configure({ timeout: 0, retries: 0 });
// { timeout: 0, retries: 0, verbose: false }
// With || this would incorrectly become { timeout: 5000, retries: 3, verbose: false }

Map and Set

ES6 introduced Map and Set as alternatives to plain objects and arrays for specific use cases.

Map

A Map is a collection of key-value pairs where keys can be any type (not just strings like regular objects).

// Creating a Map
const userRoles = new Map();

// Setting values
userRoles.set("alice", "admin");
userRoles.set("bob", "editor");
userRoles.set("charlie", "viewer");

// Getting values
console.log(userRoles.get("alice")); // "admin"
console.log(userRoles.get("dave"));  // undefined

// Checking existence
console.log(userRoles.has("bob"));   // true

// Size
console.log(userRoles.size);         // 3

// Deleting
userRoles.delete("charlie");

// Initialize with entries
const config = new Map([
    ["theme", "dark"],
    ["language", "en"],
    ["fontSize", 14]
]);

// Keys can be ANY type — even objects
const objectKey = { id: 1 };
const scores = new Map();
scores.set(objectKey, 95);
console.log(scores.get(objectKey)); // 95

// Iterating
userRoles.forEach((value, key) => {
    console.log(`${key}: ${value}`);
});

// Using for...of
for (const [key, value] of userRoles) {
    console.log(`${key} is a ${value}`);
}

// Convert to array
const entries = [...userRoles];       // [["alice","admin"], ["bob","editor"]]
const keys = [...userRoles.keys()];   // ["alice", "bob"]
const values = [...userRoles.values()]; // ["admin", "editor"]

Set

A Set is a collection of unique values. Duplicates are automatically removed.

// Creating a Set
const tags = new Set();

// Adding values
tags.add("javascript");
tags.add("tutorial");
tags.add("javascript"); // Duplicate — ignored
console.log(tags.size);  // 2

// Checking existence
console.log(tags.has("javascript")); // true

// Deleting
tags.delete("tutorial");

// Initialize with an array
const uniqueNumbers = new Set([1, 2, 3, 2, 1, 4]);
console.log([...uniqueNumbers]); // [1, 2, 3, 4]

// Common use: remove duplicates from an array
const items = ["apple", "banana", "apple", "cherry", "banana"];
const unique = [...new Set(items)];
console.log(unique); // ["apple", "banana", "cherry"]

// Iterating
tags.forEach(tag => console.log(tag));

for (const tag of tags) {
    console.log(tag);
}

// Set operations (manual)
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);

// Union
const union = new Set([...setA, ...setB]);          // {1,2,3,4,5,6}

// Intersection
const intersection = new Set([...setA].filter(x => setB.has(x))); // {3,4}

// Difference
const difference = new Set([...setA].filter(x => !setB.has(x)));  // {1,2}
💡
Browser compatibility

All features in this tutorial are supported in every modern browser (Chrome, Firefox, Safari, Edge) and Node.js 14+. If you need to support Internet Explorer or very old browsers, you will need a transpiler like Babel to convert modern syntax into compatible code. For most projects today, you can safely use all ES6+ features.

Practical Examples

Here are some real-world patterns that combine multiple ES6+ features together.

// API response processing using destructuring, spread, and arrow functions
async function loadUsers() {
    const response = await fetch("/api/users");
    const data = await response.json();

    // Destructure and transform each user
    const users = data.map(({ id, name, email, address }) => ({
        id,
        name,
        email,
        city: address?.city ?? "Unknown"
    }));

    return users;
}

// Configuration merger with defaults
function createApp(userConfig = {}) {
    const defaults = {
        theme: "dark",
        language: "en",
        debug: false,
        api: {
            baseUrl: "/api",
            timeout: 5000
        }
    };

    // Deep merge using spread
    const config = {
        ...defaults,
        ...userConfig,
        api: {
            ...defaults.api,
            ...userConfig?.api
        }
    };

    return config;
}

const app = createApp({ theme: "light", api: { timeout: 10000 } });
// { theme: "light", language: "en", debug: false,
//   api: { baseUrl: "/api", timeout: 10000 } }
// Event handler using template literals and destructuring
document.querySelector("#search").addEventListener("input", ({ target: { value } }) => {
    const results = items.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
    );
    renderResults(results);
});

// Unique tag counter using Map and Set
function analyzeTags(posts) {
    const tagCount = new Map();
    const allTags = new Set();

    for (const { tags = [] } of posts) {
        for (const tag of tags) {
            allTags.add(tag);
            tagCount.set(tag, (tagCount.get(tag) ?? 0) + 1);
        }
    }

    // Sort by count descending
    const sorted = [...tagCount.entries()]
        .sort(([, a], [, b]) => b - a);

    return {
        uniqueCount: allTags.size,
        topTags: sorted.slice(0, 5)
    };
}

Now Do It Yourself: Five Steps

Five of the modern additions you will actually reach for every day — readable strings, sensible defaults, safe access into data you did not create, the operator that finally fixed ||, and two collections that solve problems arrays are bad at. Every output below is exactly what Node printed.

1
Build a sentence out of values

Go: open a terminal, cd to a folder you can write in, and create a file called modern.js.

Do: type these six lines and run node modern.js. The slanted quote character is a backtick — on most keyboards it shares a key with ~, just left of the 1.

const name = "Sam";
const items = 3;
console.log(`${name} has ${items} items, ${items * 2} after the sale.`);
console.log('${name} has ${items} items.');
console.log(`Line one
Line two`);

You should see: Sam has 3 items, 6 after the sale., then the literal text ${name} has ${items} items., then two separate lines. Three things at once: ${...} drops a value into the string, it can hold a whole expression rather than just a name, and a backtick string may run across real line breaks. The second line is the control: ordinary quotes do none of this and print the placeholder as text.

If not: if line 3 prints ${name} as text, you used an apostrophe where a backtick belongs. They look almost identical in most editors — check the character just left of 1. If you get SyntaxError: Unexpected end of input, a backtick string was opened and never closed — the parser ran off the end of the file still looking for its partner.

2
Give a function sensible defaults

Go: same file, replace the contents.

Do: type these eight lines and run it.

function greet(name = "friend", greeting = "Hello") {
  return `${greeting}, ${name}!`;
}
console.log(greet());
console.log(greet("Sam"));
console.log(greet("Sam", "Welcome"));
console.log(greet(undefined, "Hi"));
console.log(greet(null));

You should see: Hello, friend!, Hello, Sam!, Welcome, Sam!, Hi, friend!, then Hello, null!. Read the last two carefully. Passing undefined means “I have nothing for this one”, so the default fills in — that is how you skip an early argument. Passing null means “the value is deliberately empty”, so the default does not fire and null goes straight into the sentence.

If not: if every call prints Hello, friend!, you are calling greet without arguments somewhere you meant to pass one. If you get ReferenceError: greeting is not defined, the default is written outside the brackets — defaults belong in the parameter list itself, between the round brackets.

3
Reach into data that might not be there

Go: same file, replace the contents.

Do: type these five lines and run it.

const user = { name: "Sam", address: { city: "Helsinki" } };
const ghost = { name: "Nobody" };
console.log(user.address?.city);
console.log(ghost.address?.city);
console.log(ghost.address?.city ?? "no city on file");

You should see: Helsinki, then undefined, then no city on file. The ?. means “if the thing on the left is missing, stop here and give back undefined instead of exploding”. This matters most with data you did not create yourself — an API response, a config file, a form — where a field you expected is simply absent.

If not: drop the question mark from line 4 and you get TypeError: Cannot read properties of undefined (reading 'city'). That message is worth memorising: it always means the part before the last dot was undefined, so read leftwards from the dot it names, not at the property itself.

4
See why || quietly eats valid settings

Go: same file, replace the contents.

Do: type these six lines and run it.

const settings = { volume: 0, nickname: "", theme: "dark" };
console.log(settings.volume || 50);
console.log(settings.volume ?? 50);
console.log(settings.nickname || "anonymous");
console.log(settings.nickname ?? "anonymous");
console.log(settings.missing ?? "default");

You should see: 50, then 0, then anonymous, then an empty line, then default. Line 2 is the bug: the user muted the sound, and || handed them volume 50 anyway, because 0 counts as falsy along with "", NaN and false. ?? only steps in for null and undefined — genuinely absent values — which is what you almost always meant.

If not: if line 5 prints nothing at all, that is correct — an empty string prints as an empty line. If ?? is rejected as a syntax error at all, your Node predates version 14, which is where the operator arrived — check with node --version and update.

5
Remove duplicates, then count them

Go: same file, replace the contents.

Do: type these ten lines and run it.

const tags = ["usb", "wired", "usb", "black", "wired"];
const unique = [...new Set(tags)];
console.log(unique);
console.log(unique.length);
const counts = new Map();
for (const tag of tags) {
  counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
console.log(counts);
console.log(counts.get("usb"));

You should see: [ 'usb', 'wired', 'black' ], then 3, then Map(3) { 'usb' => 2, 'wired' => 2, 'black' => 1 }, then 2. A Set refuses duplicates, so wrapping a list in one and spreading it back out is the shortest de-duplicate there is. A Map is a lookup table whose keys can be any type at all — and note how ?? 0 from the last step handles the first sighting of each tag.

If not: if line 3 prints Set(3) { 'usb', 'wired', 'black' } you left out the three dots. A Set is not an array: it has .size rather than .length and no .map, so spread it back into an array whenever you want array methods.

🎉
Check yourself before moving on

Without scrolling up: a page reads const perPage = input.perPage || 20; and a user asks for 0 results per page. What do they get, and what should the line have been? Answer: they get 20, because 0 is falsy and || replaces it. The line should read input.perPage ?? 20, which only fills in when the value is genuinely absent.

Now do it without the page: write one line that prints a user’s city, falling back to "unknown", and that does not crash when the user has no address at all. You have both halves already — ?. from step 3 and ?? from step 4, in that order.

Summary

  • Use const by default, let when reassignment is needed, never var
  • Arrow functions (=>) provide concise syntax, especially for callbacks
  • Template literals (`...${}`) replace string concatenation with cleaner interpolation
  • Destructuring unpacks values from objects and arrays into variables
  • Spread (...) expands arrays/objects; rest (...) collects remaining items
  • Default parameters eliminate the need for manual undefined checks
  • Optional chaining (?.) safely accesses nested properties without null checks
  • Nullish coalescing (??) provides defaults only for null/undefined, not all falsy values
  • Map and Set offer specialized collections for key-value pairs and unique values
🎉
Modern JavaScript unlocked!

You now know the essential ES6+ features used in every modern codebase. Next up: Modules and Package Management — learn how to organize your code into reusable modules and leverage the npm ecosystem.