JavaScript ES6+ Features
The Evolution of JavaScript
ECMAScript 2015 (commonly called ES6) was the most significant update to JavaScript in its history. It introduced a wave of new syntax, features, and capabilities that fundamentally changed how developers write JavaScript. Since then, newer features have continued to arrive each year (ES2016, ES2017, and so on), which is why we often say "ES6+" to refer to modern JavaScript as a whole.
This tutorial covers the most important and widely-used features that every JavaScript developer should know. Whether you are writing frontend code with React or backend code with Node.js, these features are essential to modern development.
let and const: Block-Scoped Variables
Before ES6, var was the only way to declare variables. The problem with var is that it is function-scoped, not block-scoped, which leads to confusing bugs. ES6 introduced let and const to fix this.
// var is function-scoped (problematic)
if (true) {
var message = "Hello";
}
console.log(message); // "Hello" — leaked out of the block!
// let is block-scoped (predictable)
if (true) {
let greeting = "Hello";
}
console.log(greeting); // ReferenceError: greeting is not defined
// const is block-scoped and cannot be reassigned
const PI = 3.14159;
PI = 3.14; // TypeError: Assignment to constant variable
// But const objects and arrays CAN be mutated
const user = { name: "Alice" };
user.name = "Bob"; // This is allowed!
user.age = 30; // This is allowed too!
// user = {}; // This would throw an error (reassignment)
const colors = ["red", "blue"];
colors.push("green"); // Allowed: mutating the array
// colors = []; // Error: reassigning the variable
const by default and only switch to let when you actually need to reassign the variable. Never use var in new code. This makes your code more predictable and easier to reason about.
Arrow Functions
Arrow functions provide a shorter syntax for writing functions and, importantly, they do not have their own this binding. This makes them ideal for callbacks, array methods, and any situation where you want to preserve the surrounding context.
// Traditional function
function add(a, b) {
return a + b;
}
// Arrow function (full form)
const add = (a, b) => {
return a + b;
};
// Arrow function (implicit return for single expression)
const add = (a, b) => a + b;
// Single parameter: parentheses are optional
const double = n => n * 2;
// No parameters: empty parentheses required
const getTimestamp = () => Date.now();
// Returning an object literal (wrap in parentheses)
const createUser = (name, age) => ({ name, age });
// Arrow functions in practice with array methods
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
The key difference with this behavior:
// Problem with traditional functions and 'this'
const timer = {
seconds: 0,
start() {
// 'this' inside a regular function refers to the caller
setInterval(function() {
this.seconds++; // BUG: 'this' is window/undefined, not timer
}, 1000);
}
};
// Solution with arrow function
const timer = {
seconds: 0,
start() {
// Arrow function inherits 'this' from the surrounding scope
setInterval(() => {
this.seconds++; // CORRECT: 'this' refers to timer
}, 1000);
}
};
Template Literals
Template literals (also called template strings) use backticks instead of quotes and support embedded expressions, multi-line strings, and tagged templates. They make string construction far more readable.
const name = "Alice";
const age = 28;
// Old way: string concatenation
const greeting = "Hello, " + name + "! You are " + age + " years old.";
// New way: template literals
const greeting = `Hello, ${name}! You are ${age} years old.`;
// Expressions inside ${}
const message = `Next year you will be ${age + 1}.`;
const status = `User is ${age >= 18 ? "an adult" : "a minor"}.`;
// Multi-line strings (no more \n or concatenation)
const html = `
<div class="card">
<h2>${name}</h2>
<p>Age: ${age}</p>
</div>
`;
// Function calls inside template literals
const items = ["apple", "banana", "cherry"];
const list = `Items: ${items.join(", ")}`;
// "Items: apple, banana, cherry"
Destructuring
Destructuring lets you extract values from objects and arrays into individual variables in a clean, concise way. It is one of the most commonly used ES6 features.
// ---- Object Destructuring ----
const user = {
name: "Alice",
age: 28,
email: "alice@example.com",
address: {
city: "Portland",
state: "OR"
}
};
// Extract properties into variables
const { name, age, email } = user;
console.log(name); // "Alice"
console.log(age); // 28
// Rename variables
const { name: userName, email: userEmail } = user;
console.log(userName); // "Alice"
// Default values
const { phone = "N/A" } = user;
console.log(phone); // "N/A" (property doesn't exist)
// Nested destructuring
const { address: { city, state } } = user;
console.log(city); // "Portland"
// ---- Array Destructuring ----
const colors = ["red", "green", "blue", "yellow"];
// Extract by position
const [first, second] = colors;
console.log(first); // "red"
console.log(second); // "green"
// Skip elements
const [, , third] = colors;
console.log(third); // "blue"
// Rest pattern (collect remaining items)
const [primary, ...rest] = colors;
console.log(primary); // "red"
console.log(rest); // ["green", "blue", "yellow"]
// Swap variables without a temp variable
let a = 1, b = 2;
[a, b] = [b, a];
console.log(a, b); // 2, 1
// ---- Destructuring in function parameters ----
function displayUser({ name, age, email = "N/A" }) {
console.log(`${name}, age ${age}, email: ${email}`);
}
displayUser(user); // "Alice, age 28, email: alice@example.com"
Spread and Rest Operators
The three dots (...) serve two related but distinct purposes. As the spread operator, it expands an iterable into individual elements. As the rest operator, it collects multiple elements into an array.
// ---- Spread Operator (expanding) ----
// Copying arrays
const original = [1, 2, 3];
const copy = [...original]; // [1, 2, 3] — a new array
// Merging arrays
const arr1 = [1, 2];
const arr2 = [3, 4];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]
// Adding elements
const withExtra = [0, ...original, 4]; // [0, 1, 2, 3, 4]
// Copying objects
const user = { name: "Alice", age: 28 };
const userCopy = { ...user };
// Merging objects (later properties overwrite earlier ones)
const defaults = { theme: "light", language: "en", fontSize: 14 };
const preferences = { theme: "dark", fontSize: 18 };
const settings = { ...defaults, ...preferences };
// { theme: "dark", language: "en", fontSize: 18 }
// Updating a specific property (immutable pattern)
const updatedUser = { ...user, age: 29 };
// { name: "Alice", age: 29 }
// Spreading into function arguments
const numbers = [5, 2, 8, 1, 9];
const max = Math.max(...numbers); // 9
// ---- Rest Operator (collecting) ----
// In function parameters
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3, 4); // 10
// Collecting remaining properties
const { name, ...otherProps } = user;
console.log(otherProps); // { age: 28 }
// Collecting remaining arguments
function log(level, ...messages) {
messages.forEach(msg => console.log(`[${level}] ${msg}`));
}
log("INFO", "Server started", "Listening on port 3000");
structuredClone(obj) (available in modern browsers and Node.js 17+).
Default Parameters
Before ES6, setting default values for function parameters required verbose patterns. Now you can set defaults directly in the function signature.
// Old way
function createUser(name, role) {
role = role || "user"; // Buggy: fails if role is "" or 0
// ...
}
// ES6 default parameters
function createUser(name, role = "user") {
return { name, role };
}
createUser("Alice"); // { name: "Alice", role: "user" }
createUser("Bob", "admin"); // { name: "Bob", role: "admin" }
// Defaults can reference earlier parameters
function createElement(tag, content, className = `${tag}-default`) {
return `<${tag} class="${className}">${content}</${tag}>`;
}
// Defaults with destructured objects
function fetchData({ url, method = "GET", headers = {} } = {}) {
console.log(`${method} ${url}`);
}
fetchData({ url: "/api/users" });
// "GET /api/users"
Modules: import and export
ES6 modules allow you to split your code into separate files and explicitly define what each file exports and what other files import. This is a massive improvement over the global scope pollution of older JavaScript.
// ---- math.js (exporting) ----
// Named exports
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// Default export (one per file)
export default class Calculator {
add(a, b) { return a + b; }
subtract(a, b) { return a - b; }
}
// ---- app.js (importing) ----
// Import named exports
import { add, multiply, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
// Import with rename
import { add as addition } from "./math.js";
// Import the default export
import Calculator from "./math.js";
const calc = new Calculator();
// Import everything as a namespace
import * as MathUtils from "./math.js";
console.log(MathUtils.add(2, 3));
console.log(MathUtils.PI);
// Dynamic import (lazy loading)
async function loadModule() {
const module = await import("./heavy-module.js");
module.doSomething();
}
<script type="module" src="app.js"></script> to enable ES module syntax. Modules are deferred by default (they wait for the HTML to parse before executing) and run in strict mode automatically.
Promises and async/await
Asynchronous programming is at the heart of JavaScript. Promises were introduced in ES6 to replace callback-based patterns, and async/await (ES2017) makes asynchronous code look and read like synchronous code.
// ---- Promises ----
// Creating a promise
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: "Alice" });
} else {
reject(new Error("Invalid user ID"));
}
}, 1000);
});
}
// Using a promise with .then() and .catch()
fetchUserData(1)
.then(user => {
console.log(user.name); // "Alice"
return fetchUserData(2);
})
.then(user2 => {
console.log(user2.name);
})
.catch(error => {
console.error("Something failed:", error.message);
})
.finally(() => {
console.log("Done, whether it succeeded or failed.");
});
// Promise.all: run multiple promises in parallel
const [user1, user2, user3] = await Promise.all([
fetchUserData(1),
fetchUserData(2),
fetchUserData(3)
]);
// ---- async/await ----
// Much cleaner syntax for the same thing
async function loadDashboard() {
try {
const user = await fetchUserData(1);
console.log(user.name);
const posts = await fetchPosts(user.id);
console.log(`${user.name} has ${posts.length} posts`);
} catch (error) {
console.error("Failed to load dashboard:", error.message);
}
}
// Real-world: fetching data from an API
async function getWeather(city) {
try {
const response = await fetch(
`https://api.example.com/weather?city=${city}`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Weather fetch failed:", error.message);
return null;
}
}
// Parallel execution with async/await
async function loadAllData() {
const [users, posts, comments] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json())
]);
console.log(users, posts, comments);
}
Array Methods: map, filter, reduce
While not new to ES6, these array methods are used everywhere in modern JavaScript (especially with arrow functions). Understanding them is essential.
const users = [
{ name: "Alice", age: 28, role: "developer" },
{ name: "Bob", age: 35, role: "designer" },
{ name: "Charlie", age: 22, role: "developer" },
{ name: "Diana", age: 31, role: "manager" },
{ name: "Eve", age: 26, role: "developer" }
];
// map: transform every element into something new
const names = users.map(user => user.name);
// ["Alice", "Bob", "Charlie", "Diana", "Eve"]
const summaries = users.map(user => `${user.name} (${user.age})`);
// ["Alice (28)", "Bob (35)", "Charlie (22)", "Diana (31)", "Eve (26)"]
// filter: keep only elements that match a condition
const developers = users.filter(user => user.role === "developer");
// [{ name: "Alice", ... }, { name: "Charlie", ... }, { name: "Eve", ... }]
const over25 = users.filter(user => user.age > 25);
// [Alice, Bob, Diana, Eve]
// reduce: combine all elements into a single value
const totalAge = users.reduce((sum, user) => sum + user.age, 0);
// 142
// Grouping with reduce
const byRole = users.reduce((groups, user) => {
const role = user.role;
if (!groups[role]) {
groups[role] = [];
}
groups[role].push(user);
return groups;
}, {});
// { developer: [...], designer: [...], manager: [...] }
// Chaining methods together
const devNames = users
.filter(user => user.role === "developer")
.map(user => user.name)
.sort();
// ["Alice", "Charlie", "Eve"]
// find: get the first matching element
const bob = users.find(user => user.name === "Bob");
// { name: "Bob", age: 35, role: "designer" }
// some: check if at least one element matches
const hasManager = users.some(user => user.role === "manager");
// true
// every: check if all elements match
const allAdults = users.every(user => user.age >= 18);
// true
// findIndex: get the index of the first match
const charlieIndex = users.findIndex(user => user.name === "Charlie");
// 2
// includes (for simple arrays)
const fruits = ["apple", "banana", "cherry"];
fruits.includes("banana"); // true
Optional Chaining (?.) and Nullish Coalescing (??)
These two operators (ES2020) solve one of the most common pain points in JavaScript: safely accessing deeply nested properties and providing default values for null or undefined.
const user = {
name: "Alice",
address: {
street: "123 Main St",
city: "Portland"
},
getFullName() {
return "Alice Smith";
}
};
// ---- Optional Chaining (?.) ----
// Without optional chaining (verbose and fragile)
const city = user && user.address && user.address.city;
// With optional chaining (clean and safe)
const city = user?.address?.city; // "Portland"
const zip = user?.address?.zipCode; // undefined (no error!)
const country = user?.location?.country; // undefined (no error!)
// Works with function calls
const fullName = user?.getFullName?.(); // "Alice Smith"
const missing = user?.nonExistent?.(); // undefined (no error!)
// Works with arrays
const users = [{ name: "Alice" }, { name: "Bob" }];
const firstName = users?.[0]?.name; // "Alice"
const thirdUser = users?.[2]?.name; // undefined
// ---- Nullish Coalescing (??) ----
// The problem with || for defaults
const count = 0;
const result = count || 10; // 10 — wrong! 0 is a valid value
const result = count ?? 10; // 0 — correct! Only null/undefined trigger default
// ?? only falls back for null or undefined
const a = null ?? "default"; // "default"
const b = undefined ?? "default"; // "default"
const c = 0 ?? "default"; // 0
const d = "" ?? "default"; // ""
const e = false ?? "default"; // false
// Real-world usage
function getUserSettings(settings) {
return {
theme: settings?.theme ?? "light",
fontSize: settings?.fontSize ?? 14,
language: settings?.language ?? "en",
notifications: settings?.notifications ?? true
};
}
getUserSettings(null);
// { theme: "light", fontSize: 14, language: "en", notifications: true }
getUserSettings({ theme: "dark", fontSize: 0, notifications: false });
// { theme: "dark", fontSize: 0, language: "en", notifications: false }
// Note: fontSize 0 and notifications false are preserved!
?? instead of || for defaults when the value might legitimately be 0, "", or false. The || operator treats all falsy values as "missing," while ?? only treats null and undefined as missing.
Enhanced Object Literals
ES6 introduced several shorthand syntaxes that make working with objects much cleaner.
const name = "Alice";
const age = 28;
// Property shorthand (when variable name matches property name)
const user = { name, age };
// Equivalent to: { name: name, age: age }
// Method shorthand
const calculator = {
add(a, b) { return a + b; },
subtract(a, b) { return a - b; }
};
// Instead of: add: function(a, b) { return a + b; }
// Computed property names
const key = "email";
const user = {
name: "Alice",
[key]: "alice@example.com", // email: "alice@example.com"
[`${key}Verified`]: true // emailVerified: true
};
// Combining everything
function createResponse(status, data, message) {
return {
status,
data,
message,
timestamp: Date.now(),
format() {
return JSON.stringify(this, null, 2);
}
};
}
Summary and Next Steps
The ES6+ features covered in this tutorial form the foundation of modern JavaScript development. Here is a recap of what you have learned:
- let/const — Block-scoped variables that replace var
- Arrow functions — Concise syntax with lexical this binding
- Template literals — String interpolation with backticks
- Destructuring — Extract values from objects and arrays
- Spread/rest operators — Expand and collect elements
- Default parameters — Clean default values in function signatures
- Modules — import/export for organized code
- Promises and async/await — Elegant asynchronous programming
- Array methods — map, filter, reduce, find, some, every
- Optional chaining and nullish coalescing — Safe property access and defaults
These features are used in virtually every modern JavaScript codebase, framework, and library. Mastering them will prepare you for working with React, Vue, Angular, Node.js, and beyond. In the next tutorial, we will put these skills to work by learning React.js for Beginners.