JavaScript has evolved dramatically since ES6 (2015). Each year brings new features that make code more concise, readable, and powerful. This guide covers the most important ES6+ features every developer should be using daily — from basics like arrow functions to newer additions like optional chaining and the using keyword.

You do not need to memorize the specification year for each feature — browser and Node.js support for everything below is universal at this point. What matters is recognizing where each feature removes boilerplate or eliminates a whole class of bugs, which is what the notes under each example focus on.

let & const (ES6)

var is function-scoped and hoisted, which means a variable declared inside an if block or for loop leaks out into the surrounding function — a design flaw responsible for countless subtle bugs, especially in loops that create callbacks. let and const are block-scoped: they exist only inside the nearest pair of curly braces, which matches how programmers naturally reason about scope.

Default to const and reach for let only when you genuinely reassign. One nuance the example highlights: const prevents reassignment of the binding, not mutation of the value. A const array can still be pushed to and a const object's properties can still change — if you need true immutability, that is what Object.freeze() is for.

Block Scoping
// const for values that never change (most variables)
const API_URL = "https://api.example.com";
const users = []; // The array is const, but its contents can change
users.push("Alice"); // ✅ Fine

// let for values that change
let count = 0;
count++;

// ❌ Never use var — it has function scope, not block scope

Arrow Functions (ES6)

Arrow functions are more than compact syntax. Their defining behavior is that they do not create their own this — they inherit it from the enclosing scope. That single property eliminated the old const self = this; workaround that plagued pre-ES6 codebases whenever a callback needed access to the surrounding object.

The same property is also why arrows are the wrong choice in some places: object methods that rely on this referring to the object, and constructors (arrows cannot be called with new). Use them for callbacks and short transformations, as in the filter and map examples below, and use regular functions or method shorthand when this matters.

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

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

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

// Multi-line — needs braces and explicit return
const processUser = (user) => {
  const name = user.name.trim();
  return { ...user, name };
};

// Great for callbacks
const evens = numbers.filter(n => n % 2 === 0);
const names = users.map(u => u.name);

Destructuring (ES6)

Destructuring unpacks values from objects and arrays directly into variables, replacing lines of repetitive property access. It shines in function signatures: function greet({ name, age }) documents exactly which properties the function uses, and callers can pass a single object instead of remembering positional arguments. Combined with default values like role = "viewer", it also replaces manual "if undefined, use fallback" checks.

One caution: nested destructuring such as const { address: { city } } = user; throws a TypeError if address is null or undefined. For data from an API where fields may be missing, either provide a default (const { address = {} } = user;) or use optional chaining, covered later in this article.

Object & Array Destructuring
// Object destructuring
const { name, email, role = "viewer" } = user;

// Rename variables
const { name: userName, email: userEmail } = user;

// Nested destructuring
const { address: { city, country } } = user;

// Array destructuring
const [first, second, ...rest] = [1, 2, 3, 4, 5];
// first = 1, second = 2, rest = [3, 4, 5]

// Swap variables
let a = 1, b = 2;
[a, b] = [b, a]; // a = 2, b = 1

// Function parameters
function greet({ name, age }) {
  return `Hi ${name}, you're ${age}`;
}

Spread & Rest Operators (ES6)

The same three dots do two opposite jobs depending on position. In a call or literal, spread expands an iterable into individual elements — the idiomatic way to merge arrays or make a modified copy of an object without touching the original, which is why it appears everywhere in React and Redux code where state must be updated immutably. In a parameter list, rest gathers any number of arguments into a real array, replacing the old array-like arguments object.

The critical caveat: spread copies are shallow. { ...originalObj } duplicates the top level only; nested objects are still shared references, so mutating copy.settings.theme also changes the original. When you need a fully independent copy of nested data, use structuredClone() instead.

Spread & Rest
// Spread — expand arrays/objects
const merged = [...arr1, ...arr2];
const copy = { ...originalObj, name: "New Name" };

// Rest — collect remaining arguments
function sum(...numbers) {
  return numbers.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3, 4); // 10

Template Literals (ES6)

Backtick strings fix three long-standing annoyances at once: interpolation replaces fiddly + concatenation, strings can span multiple lines without \n juggling, and the tagged form lets a function preprocess the string parts and interpolated values before they are combined. Tagged templates are the mechanism behind libraries like styled-components and safe-HTML helpers.

A word of warning on the multi-line HTML pattern shown below: interpolating raw user input into HTML strings is a textbook XSS vulnerability. If any interpolated value can originate from a user, escape it first — which is precisely the kind of job a tagged template like the highlight example can be adapted to do automatically.

Template Literals
const name = "Alice";
const greeting = `Hello, ${name}!`;

// Multi-line strings
const html = `
  <div class="card">
    <h2>${title}</h2>
    <p>${description}</p>
  </div>
`;

// Tagged templates
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) =>
    `${result}${str}<mark>${values[i] || ''}</mark>`, '');
}

Optional Chaining & Nullish Coalescing (ES2020)

Before ES2020, safely reading a deeply nested property meant chains of guards like user && user.address && user.address.city. Optional chaining collapses that to user?.address?.city: the moment any link is null or undefined, evaluation stops and the whole expression yields undefined instead of throwing. As the example shows, it works for property access, array indexing, and even method calls.

Nullish coalescing (??) is its natural partner, and the comparison with || at the end of the example is the part worth internalizing. || falls back on any falsy value, so a legitimate 0, empty string, or false gets replaced by the default — a real bug when 0 is a valid port, count, or volume setting. ?? falls back only on null and undefined. One habit to avoid: sprinkling ?. everywhere "just in case" can mask genuine bugs by silently turning them into undefined; use it where absence is truly a valid state.

?. and ??
// Optional chaining — safely access nested properties
const city = user?.address?.city;          // undefined if any is null/undefined
const first = users?.[0]?.name;            // Array access
const result = user?.getProfile?.();       // Method call

// Nullish coalescing — default for null/undefined ONLY
const port = config.port ?? 3000;          // Uses 3000 only if port is null/undefined
const name = user.name ?? "Anonymous";

// Compare with || (which also catches 0, "", false)
const count = data.count || 10;   // ⚠️ If count is 0, this returns 10!
const count = data.count ?? 10;   // ✅ Only returns 10 if count is null/undefined

Object Shorthand & Computed Properties (ES6)

These are small conveniences you end up using daily. Property shorthand removes the redundancy of { name: name }, and method shorthand gives object literals clean function definitions. Computed property names are the interesting one: they let an expression determine a key at creation time, which previously required creating the object first and assigning with bracket notation afterwards. It is particularly handy for building dynamic form-state updates, where the changed field's name arrives as a variable rather than being known in advance.

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

// Property shorthand
const user = { name, age }; // same as { name: name, age: age }

// Method shorthand
const api = {
  async getUser(id) { /* ... */ },
  async createUser(data) { /* ... */ },
};

// Computed property names
const field = "email";
const obj = { [field]: "[email protected]" };
// { email: "[email protected]" }

Modules: import/export (ES6)

ES modules gave JavaScript an official answer to a question that had spawned competing conventions (CommonJS, AMD) for years: how to split code across files with explicit dependencies. Named exports work best for utility collections — they force importers to use consistent names, and editors can autocomplete them. Default exports suit modules whose entire purpose is one class or component. Static import declarations also enable tree-shaking: because dependencies are analyzable before the code runs, bundlers can prove which exports are unused and drop them from the production build.

Dynamic import() is the performance lever. Because it returns a promise and can run anywhere, you can defer loading heavy dependencies — a charting library, a rich-text editor — until the user actually opens the feature that needs them. This is the foundation of route-based code splitting in every modern framework.

ES Modules
// Named exports
export const API_URL = "https://api.example.com";
export function fetchUser(id) { /* ... */ }

// Default export
export default class UserService { /* ... */ }

// Named imports
import { API_URL, fetchUser } from './api.js';

// Default import
import UserService from './UserService.js';

// Rename on import
import { fetchUser as getUser } from './api.js';

// Dynamic import (lazy loading)
const { Chart } = await import('./chart.js');

Newer Features Worth Knowing

The language keeps evolving in smaller, focused steps. These recent additions are worth adopting as soon as your runtime targets allow, because each one replaces a common workaround with a proper built-in:

  • structuredClone() (ES2022): Deep clone objects without JSON.parse(JSON.stringify()) hacks.
  • Array.at() (ES2022): arr.at(-1) to get the last element.
  • Object.groupBy() (ES2024): Group array items by a key function.
  • Top-level await (ES2022): Use await outside async functions in ES modules.

Adopting These Features in Real Projects

If you are modernizing an older codebase, do it opportunistically rather than in one sweeping rewrite: convert var to const/let and replace || defaults with ?? in the files you already touch. Linters automate much of the discipline — ESLint rules such as prefer-const, no-var, and prefer-template will flag legacy patterns as you go. For syntax newer than your oldest supported browser, a build step with a transpiler and the appropriate target settings closes the gap without you having to think about it feature by feature.

The pay-off compounds: destructured parameters make function contracts self-documenting, optional chaining eliminates a whole family of "cannot read property of undefined" crashes, and modules plus dynamic import directly shrink what you ship. Learn these once, and every file you write afterwards gets a little shorter and a little safer.

Try Our Free JavaScript Tools

Minify and beautify your JavaScript instantly.