JavaScript has evolved from a simple scripting language into a powerful, versatile platform that powers everything from web applications to server-side systems. At the heart of this transformation lies ES6 (ECMAScript 2015) and the subsequent annual releases that continue to modernise the language.
If you learned JavaScript years ago and haven’t kept up, or if you’re just starting your development journey, understanding modern JavaScript features isn’t optional—it’s essential. These features don’t just make code prettier; they solve real problems, prevent bugs, and enable architectural patterns that were previously impossible.
Let’s explore the ES6+ features that every developer must know in 2025, with practical examples showing why they matter and how to use them effectively.
Why ES6+ Matters
Before diving into specific features, let’s address why this matters. JavaScript used to be inconsistent, verbose, and prone to subtle bugs. Variables leaked into unexpected scopes, asynchronous code created callback hell, and simple operations required verbose workarounds.
ES6 and subsequent releases fundamentally changed JavaScript. They introduced features that other languages had for years, fixed design mistakes from the past, and created new capabilities that enable modern application architectures.
Companies building production applications expect developers to write modern JavaScript. Code reviews reject pre-ES6 patterns. Job interviews test these features. Framework documentation assumes you know them. Understanding ES6+ isn’t about following trends—it’s about using the language properly.
The Foundation: Let and Const
Before ES6, JavaScript had only one way to declare variables: var. This created problems that plagued developers for decades.
The Problem with Var
Variables declared with var are function-scoped, not block-scoped. This means they leak outside of if statements, loops, and other blocks where you’d expect them to be contained:
function oldWay() {
if (true) {
var message = "This leaks";
}
console.log(message); // Works, but shouldn't
}
Variables declared with var also get hoisted—moved to the top of their scope during execution—which creates confusing behaviour:
console.log(name); // undefined, not an error
var name = "Alice";
Block Scoping with Let and Const
ES6 introduced let and const for block-scoped variable declarations. Block-scoped means variables only exist within the nearest enclosing curly braces:
function modernWay() {
if (true) {
let message = "This is contained";
const MAX_RETRIES = 3;
}
// console.log(message); // Error: message is not defined
}
Use const for values that shouldn’t be reassigned. This doesn’t make objects immutable—you can still modify object properties—but it prevents reassigning the variable itself:
const user = { name: "Alice", age: 30 };
user.age = 31; // Allowed - modifying properties
// user = {}; // Error - cannot reassign const
Use let when you need to reassign the variable:
let counter = 0;
counter++; // Allowed
counter = 10; // Allowed
Best Practice: Default to const. Only use let when you know you’ll reassign the variable. Never use var in modern code.
Arrow Functions: Concise and Predictable
Arrow functions provide shorter syntax and fix the notorious this binding problem that confused JavaScript developers for years.
The Traditional Function Problem
Traditional functions create their own this context, which creates problems in callbacks and methods:
class Counter {
constructor() {
this.count = 0;
}
startCounting() {
setInterval(function() {
this.count++; // Error: 'this' is undefined or window
console.log(this.count);
}, 1000);
}
}
Developers worked around this with .bind(this), storing this in a variable, or other hacks.
Arrow Functions to the Rescue
Arrow functions don’t create their own this context—they inherit it from the surrounding scope:
class Counter {
constructor() {
this.count = 0;
}
startCounting() {
setInterval(() => {
this.count++; // Works perfectly
console.log(this.count);
}, 1000);
}
}
Concise Syntax
Arrow functions also provide shorter syntax for simple operations:
// Traditional
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(n) {
return n * 2;
});
// Arrow function - explicit return
const doubled = numbers.map((n) => {
return n * 2;
});
// Arrow function - implicit return
const doubled = numbers.map(n => n * 2);
When the function body is a single expression, you can omit the curly braces and return keyword. For single parameters, you can even omit the parentheses.
When Not to Use Arrow Functions
Don’t use arrow functions for object methods when you need the object’s this:
const person = {
name: "Alice",
// Don't do this
greet: () => {
console.log(`Hello, ${this.name}`); // 'this' doesn't refer to person
},
// Do this instead
greet() {
console.log(`Hello, ${this.name}`); // Works correctly
}
};
Template Literals: String Interpolation Done Right
Template literals revolutionised string handling in JavaScript. Before ES6, string concatenation was verbose and error-prone.
The Old Way
Traditional string concatenation required careful attention to quotes and plus signs:
const name = "Alice";
const age = 30;
const message = "Hello, " + name + "! You are " + age + " years old.";
// Multi-line strings were painful
const html = "<div>\n" +
" <h1>Title</h1>\n" +
" <p>Content</p>\n" +
"</div>";
Template Literals
Template literals use backticks and provide string interpolation with ${}:
const name = "Alice";
const age = 30;
const message = `Hello, ${name}! You are ${age} years old.`;
// Multi-line strings are natural
const html = `
<div>
<h1>Title</h1>
<p>Content</p>
</div>
`;
You can embed any valid JavaScript expression inside ${}:
const a = 5;
const b = 10;
console.log(`The sum of ${a} and ${b} is ${a + b}`);
const user = { name: "Bob", admin: true };
console.log(`Welcome, ${user.admin ? "Admin" : "User"} ${user.name}`);
Tagged Templates
Advanced usage allows you to process template literals with functions:
function highlight(strings, ...values) {
return strings.reduce((result, string, i) => {
return `${result}${string}<strong>${values[i] || ''}</strong>`;
}, '');
}
const name = "Alice";
const age = 30;
const result = highlight`Name: ${name}, Age: ${age}`;
// "Name: <strong>Alice</strong>, Age: <strong>30</strong>"
Destructuring: Extract Values Elegantly
Destructuring allows extracting values from arrays and objects into distinct variables with concise syntax.
Array Destructuring
Extract array elements into variables:
// Without destructuring
const colors = ["red", "green", "blue"];
const first = colors[0];
const second = colors[1];
// With destructuring
const [first, second] = colors;
console.log(first); // "red"
console.log(second); // "green"
// Skip elements
const [, , third] = colors;
console.log(third); // "blue"
// Rest elements
const [primary, ...others] = colors;
console.log(primary); // "red"
console.log(others); // ["green", "blue"]
Object Destructuring
Extract object properties into variables:
// Without destructuring
const user = { name: "Alice", age: 30, city: "Paris" };
const name = user.name;
const age = user.age;
// With destructuring
const { name, age } = user;
console.log(name); // "Alice"
console.log(age); // 30
// Rename variables
const { name: userName, age: userAge } = user;
console.log(userName); // "Alice"
// Default values
const { country = "Unknown" } = user;
console.log(country); // "Unknown"
Nested Destructuring
Handle deeply nested structures:
const response = {
data: {
user: {
name: "Alice",
address: {
city: "Paris"
}
}
}
};
const { data: { user: { address: { city } } } } = response;
console.log(city); // "Paris"
Function Parameters
Destructuring shines in function parameters:
// Instead of accessing properties
function displayUser(user) {
console.log(user.name);
console.log(user.email);
}
// Destructure in parameters
function displayUser({ name, email, role = "user" }) {
console.log(name);
console.log(email);
console.log(role);
}
displayUser({ name: "Alice", email: "alice@example.com" });
Spread and Rest Operators: Three Dots of Power
The spread (...) and rest operators look identical but serve different purposes depending on context.
Spread Operator: Expanding Values
The spread operator expands arrays or objects into individual elements:
// Array spreading
const numbers = [1, 2, 3];
const moreNumbers = [0, ...numbers, 4, 5];
console.log(moreNumbers); // [0, 1, 2, 3, 4, 5]
// Combine arrays
const arr1 = [1, 2];
const arr2 = [3, 4];
const combined = [...arr1, ...arr2];
// Copy arrays (shallow copy)
const original = [1, 2, 3];
const copy = [...original];
// Object spreading
const user = { name: "Alice", age: 30 };
const updatedUser = { ...user, age: 31, city: "Paris" };
console.log(updatedUser);
// { name: "Alice", age: 31, city: "Paris" }
// Merge objects
const defaults = { theme: "light", language: "en" };
const userPrefs = { language: "fr" };
const settings = { ...defaults, ...userPrefs };
// { theme: "light", language: "fr" }
Rest Operator: Collecting Values
The rest operator collects multiple elements into an array:
// Function parameters
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
// Array destructuring
const [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(first); // 1
console.log(rest); // [3, 4, 5]
// Object destructuring
const { name, age, ...otherProps } = {
name: "Alice",
age: 30,
city: "Paris",
country: "France"
};
console.log(otherProps); // { city: "Paris", country: "France" }
Default Parameters: Sane Function Arguments
Default parameters eliminate the need for parameter-checking boilerplate.
The Old Way
Before ES6, checking for undefined parameters required verbose code:
function createUser(name, role, active) {
name = name || "Anonymous";
role = role || "user";
active = active !== undefined ? active : true;
// ...
}
Default Parameters
ES6 allows specifying defaults directly in the parameter list:
function createUser(name = "Anonymous", role = "user", active = true) {
return { name, role, active };
}
console.log(createUser());
// { name: "Anonymous", role: "user", active: true }
console.log(createUser("Alice", "admin"));
// { name: "Alice", role: "admin", active: true }
Default parameters can reference earlier parameters:
function greet(name, greeting = `Hello, ${name}!`) {
console.log(greeting);
}
greet("Alice"); // "Hello, Alice!"
Promises: Taming Asynchronous Code
Promises revolutionised asynchronous JavaScript programming, replacing callback hell with chainable, readable code.
The Callback Hell Problem
Before promises, nested callbacks created unreadable code:
getData(function(data) {
processData(data, function(processed) {
saveData(processed, function(saved) {
updateUI(saved, function() {
console.log("Done!");
});
});
});
});
Promises to the Rescue
Promises represent eventual completion or failure of asynchronous operations:
getData()
.then(data => processData(data))
.then(processed => saveData(processed))
.then(saved => updateUI(saved))
.then(() => console.log("Done!"))
.catch(error => console.error("Error:", error));
Creating Promises
function delay(ms) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject(new Error("Delay must be positive"));
} else {
setTimeout(() => resolve(`Waited ${ms}ms`), ms);
}
});
}
delay(1000)
.then(message => console.log(message))
.catch(error => console.error(error));
Promise.all and Promise.race
Handle multiple promises concurrently:
// Wait for all promises
Promise.all([
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments')
])
.then(([users, posts, comments]) => {
console.log("All data loaded");
})
.catch(error => {
console.error("One of the requests failed:", error);
});
// Use first resolved promise
Promise.race([
fetch('/api/primary'),
fetch('/api/backup')
])
.then(response => {
console.log("Got response from fastest server");
});
Async/Await: Promises Made Beautiful
Async/await, introduced in ES2017, provides syntax that makes asynchronous code look and behave like synchronous code.
From Promises to Async/Await
While promises are powerful, chaining can still become complex:
// Promise chains
function getUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => fetch(`/api/posts/${user.id}`))
.then(response => response.json())
.then(posts => ({ user, posts }));
}
Async/await makes this dramatically more readable:
async function getUser(id) {
const userResponse = await fetch(`/api/users/${id}`);
const user = await userResponse.json();
const postsResponse = await fetch(`/api/posts/${user.id}`);
const posts = await postsResponse.json();
return { user, posts };
}
Error Handling
Use try/catch for error handling with async/await:
async function fetchUserData(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch user:", error);
throw error; // Re-throw or handle appropriately
}
}
Parallel Execution
Await promises concurrently when they don’t depend on each other:
// Sequential - slow (waits for each)
async function sequential() {
const user = await fetchUser(); // 1s
const posts = await fetchPosts(); // 1s
const comments = await fetchComments(); // 1s
// Total: 3s
}
// Parallel - fast (all at once)
async function parallel() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
// Total: 1s (assuming all take 1s)
}
Modules: Organising Code
ES6 modules provide a standardised way to organise and share code across files.
Export
Export functions, classes, or variables from modules:
// utils.js
export function formatDate(date) {
return date.toLocaleDateString();
}
export const MAX_RETRIES = 3;
export class User {
constructor(name) {
this.name = name;
}
}
// Default export
export default function authenticate(credentials) {
// Authentication logic
}
Import
Import export values in other files:
// Named imports
import { formatDate, MAX_RETRIES, User } from './utils.js';
// Default import
import authenticate from './utils.js';
// Import everything
import * as utils from './utils.js';
console.log(utils.MAX_RETRIES);
// Rename imports
import { formatDate as format } from './utils.js';
Dynamic Imports
Load modules conditionally or on-demand:
async function loadFeature() {
if (userWantsAdvancedFeatures) {
const module = await import('./advanced-features.js');
module.initializeAdvanced();
}
}
Classes: Object-Oriented JavaScript
ES6 classes provide syntactic sugar over JavaScript’s prototype-based inheritance, making object-oriented programming more familiar.
Basic Class Syntax
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
return `Hello, I'm ${this.name}`;
}
// Getter
get displayName() {
return this.name.toUpperCase();
}
// Setter
set displayName(value) {
this.name = value;
}
// Static method
static createGuest() {
return new User("Guest", "guest@example.com");
}
}
const user = new User("Alice", "alice@example.com");
console.log(user.greet()); // "Hello, I'm Alice"
console.log(user.displayName); // "ALICE"
const guest = User.createGuest();
Inheritance
Extend classes with extends and call parent methods with super:
class Admin extends User {
constructor(name, email, permissions) {
super(name, email); // Call parent constructor
this.permissions = permissions;
}
greet() {
return `${super.greet()} (Admin)`;
}
hasPermission(permission) {
return this.permissions.includes(permission);
}
}
const admin = new Admin("Bob", "bob@example.com", ["read", "write"]);
console.log(admin.greet()); // "Hello, I'm Bob (Admin)"
console.log(admin.hasPermission("write")); // true
Private Fields
Modern JavaScript supports truly private class fields:
class BankAccount {
#balance = 0; // Private field
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.deposit(100);
console.log(account.getBalance()); // 100
// console.log(account.#balance); // Error: Private field
Enhanced Object Literals
ES6 enhanced object literals with shorthand properties, computed property names, and method definitions.
Shorthand Properties
When the property name matches the variable name:
const name = "Alice";
const age = 30;
// Old way
const user = { name: name, age: age };
// Shorthand
const user = { name, age };
Shorthand Methods
// Old way
const calculator = {
add: function(a, b) {
return a + b;
}
};
// Shorthand
const calculator = {
add(a, b) {
return a + b;
}
};
Computed Property Names
const propertyName = "dynamicKey";
const obj = {
[propertyName]: "value",
[`${propertyName}_2`]: "another value"
};
console.log(obj.dynamicKey); // "value"
console.log(obj.dynamicKey_2); // "another value"
Optional Chaining and Nullish Coalescing
These ES2020 features prevent errors when accessing nested properties and provide better default value handling.
**Optional Chaining (?.)
**
Access nested properties safely without manual null checks:
// Without optional chaining
const city = user && user.address && user.address.city;
// With optional chaining
const city = user?.address?.city;
// Works with arrays
const firstPost = user?.posts?.[0];
// Works with functions
const result = obj.method?.();
If any part of the chain is null or undefined, the entire expression returns undefined without throwing errors.
Nullish Coalescing (??)
Provide default values only for null or undefined:
// Logical OR - treats 0, false, "" as falsy
const count = 0;
const displayCount = count || 10; // 10 (unexpected)
// Nullish coalescing - only null/undefined
const displayCount = count ?? 10; // 0 (correct)
// Useful for configuration
const config = {
timeout: userTimeout ?? 5000,
retry: userRetry ?? 3
};
Array Methods: Modern Data Manipulation
ES6+ introduced powerful array methods that make data manipulation cleaner and more functional.
Map, Filter, Reduce
Transform, filter, and aggregate data:
const numbers = [1, 2, 3, 4, 5];
// Map - transform each element
const doubled = numbers.map(n => n * 2);
// [2, 4, 6, 8, 10]
// Filter - keep elements matching condition
const evens = numbers.filter(n => n % 2 === 0);
// [2, 4]
// Reduce - aggregate to single value
const sum = numbers.reduce((total, n) => total + n, 0);
// 15
// Chain methods
const result = numbers
.filter(n => n > 2)
.map(n => n * 2)
.reduce((total, n) => total + n, 0);
// 24 (3*2 + 4*2 + 5*2)
Find and FindIndex
Locate elements in arrays:
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" }
];
// Find first matching element
const bob = users.find(user => user.name === "Bob");
// Find index of first match
const bobIndex = users.findIndex(user => user.name === "Bob");
Some and Every
Test array conditions:
const numbers = [1, 2, 3, 4, 5];
// Some - at least one matches
const hasEven = numbers.some(n => n % 2 === 0); // true
// Every - all must match
const allPositive = numbers.every(n => n > 0); // true
const allEven = numbers.every(n => n % 2 === 0); // false
Modern Features Worth Knowing
JavaScript continues evolving with new features in recent releases.
Array.at() – ES2022
Access array elements with negative indices:
const arr = [1, 2, 3, 4, 5];
// Traditional
const last = arr[arr.length - 1]; // 5
// Modern
const last = arr.at(-1); // 5
const secondLast = arr.at(-2); // 4
Object.groupBy() – ES2024
Group array elements by key:
const people = [
{ name: "Alice", city: "Paris" },
{ name: "Bob", city: "London" },
{ name: "Charlie", city: "Paris" }
];
const grouped = Object.groupBy(people, person => person.city);
// {
// Paris: [{ name: "Alice", ... }, { name: "Charlie", ... }],
// London: [{ name: "Bob", ... }]
// }
Top-Level Await – ES2022
Use await at the module top level:
// module.js
const data = await fetch('/api/data');
const json = await data.json();
export default json;
Practical Application: Putting It Together
Let’s see these features working together in a realistic example:
// Modern API service class
class APIService {
#baseURL = 'https://api.example.com';
#timeout = 5000;
async fetchUser(id) {
try {
const response = await fetch(
`${this.#baseURL}/users/${id}`,
{ signal: AbortSignal.timeout(this.#timeout) }
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Failed to fetch user ${id}:`, error);
throw error;
}
}
async fetchUserWithPosts(id) {
const [user, posts] = await Promise.all([
this.fetchUser(id),
this.fetchPosts(id)
]);
return { ...user, posts };
}
formatUser({ name, email, role = 'user', ...rest }) {
return {
displayName: name.toUpperCase(),
contact: email,
role,
metadata: rest
};
}
}
// Usage
const api = new APIService();
const users = await api.fetchUserWithPosts(1);
const formatted = users.map(user => api.formatUser(user));
const admins = formatted.filter(user => user.role === 'admin');
console.log(`Found ${admins.length} admin(s)`);
This example demonstrates classes with private fields, async/await, destructuring, spread operators, template literals, arrow functions, and array methods—all working together naturally.
Best Practices and Common Pitfalls
Do:
- Use
constby default,letwhen reassignment is needed - Prefer arrow functions for callbacks and short functions
- Use async/await for cleaner asynchronous code
- Destructure function parameters for clarity
- Chain array methods for data transformations
Don’t:
- Use
varin modern code - Mix callbacks and promises unnecessarily
- Forget to handle promise rejections
- Overuse destructuring when simple property access is clearer
- Ignore browser compatibility requirements
Performance Considerations:
- Spread operators create shallow copies—be aware with large data
- Array methods create new arrays—consider performance for huge datasets
- Async/await has minimal overhead but adds microtasks to the event loop
Browser Support and Transpilation
Most modern browsers support ES6+ features, but you might need to support older browsers. Tools like Babel transpile modern JavaScript to older versions:
// Modern code you write
const doubled = numbers.map(n => n * 2);
// Transpiled for older browsers
var doubled = numbers.map(function(n) {
return n * 2;
});
Build tools like Webpack, Vite, or Parcel typically include Babel automatically, handling transpilation during the build process.
Conclusion
ES6+ transformed JavaScript from a quirky scripting language into a modern, powerful platform. The features we’ve explored aren’t just syntax sugar—they solve real problems, prevent bugs, and enable architectural patterns that weren’t possible before.
Master these features, and you’ll write cleaner code, debug faster, and understand modern frameworks better. More importantly, you’ll think in modern JavaScript patterns rather than working around language limitations.
JavaScript continues evolving with annual ECMAScript releases. Stay current by following what’s coming next, but focus first on mastering these foundational ES6+ features. They form the bedrock of modern JavaScript development and will serve you for years to come.
Start using these features in your next project. Read other developers’ code to see patterns in action. The investment in learning modern JavaScript pays dividends throughout your development career.

