ES6+ Features Every Developer Must Know

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 const by default, let when 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 var in 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.

Leave a Reply

Your email address will not be published. Required fields are marked *