← Back to Tutorials

JavaScript ES6 Features: A Practical Guide for Intermediate Developers

javascriptes6ecmascript 2015modern javascriptarrow functionsdestructuringpromisesmodulesclasseslet const

JavaScript ES6 Features: A Practical Guide for Intermediate Developers

ES6 (ECMAScript 2015) was a major evolution of JavaScript that introduced new syntax and capabilities aimed at making code more expressive, safer, and easier to maintain. If you already write JavaScript professionally, ES6 features are likely part of your daily work—but mastering their nuances (and knowing when not to use them) is what separates “it works” from “it scales.”

This tutorial is a practical, intermediate-level guide with deeper explanations, real commands, and runnable examples.


Table of Contents

  1. Prerequisites and Setup
  2. let and const: Block Scope and Immutability
  3. Arrow Functions: Syntax, this, and Pitfalls
  4. Template Literals: Beyond String Interpolation
  5. Destructuring: Objects, Arrays, Defaults, and Patterns
  6. Default Parameters and Rest/Spread
  7. Enhanced Object Literals
  8. Classes: Syntax Sugar with Real Consequences
  9. Modules: import/export in Practice
  10. Promises: Composition and Error Handling
  11. Iterators and Generators
  12. Symbols and Well-Known Symbols
  13. Collections: Map, Set, WeakMap, WeakSet
  14. New Built-ins and Methods Worth Knowing
  15. Transpiling and Tooling: Babel + ESLint
  16. Practical Patterns and Guidance

Prerequisites and Setup

You can run all examples with Node.js. ES6 is broadly supported in modern Node versions, but modules may require configuration depending on your environment.

Install Node and verify

node -v
npm -v

Create a project folder:

mkdir es6-practical-guide
cd es6-practical-guide
npm init -y

Create a file for experiments:

touch index.js
node index.js

Using ES modules in Node

Node supports ES modules (ESM), but you must opt in via either:

  1. "type": "module" in package.json, or
  2. .mjs file extension.

To enable ESM via package.json:

npm pkg set type=module
cat package.json

Now you can use import/export in .js files.


let and const: Block Scope and Immutability

var problems: function scope and hoisting surprises

var is function-scoped, not block-scoped. That means variables declared inside if, for, or {} blocks leak outside the block.

if (true) {
  var x = 1;
}
console.log(x); // 1 (leaks)

Also, var is hoisted in a way that can produce confusing behavior:

console.log(a); // undefined (not ReferenceError)
var a = 10;

let: block scope + temporal dead zone (TDZ)

let is block-scoped:

if (true) {
  let y = 2;
}
console.log(y); // ReferenceError: y is not defined

Unlike var, let declarations are hoisted but not initialized until the declaration is evaluated. Accessing them before declaration triggers the Temporal Dead Zone:

console.log(z); // ReferenceError
let z = 3;

Why TDZ matters: It prevents subtle bugs where you accidentally read an uninitialized variable.

const: binding immutability, not value immutability

const prevents reassignment of the binding:

const n = 1;
n = 2; // TypeError

But if the value is an object, you can still mutate the object:

const user = { name: "Ava" };
user.name = "Mina"; // allowed

If you want shallow immutability, use Object.freeze:

const config = Object.freeze({ env: "prod" });
// config.env = "dev"; // fails silently in non-strict, throws in strict mode

Practical guideline:


Arrow Functions: Syntax, this, and Pitfalls

Arrow functions provide shorter syntax and lexically bind this.

Syntax basics

const add = (a, b) => a + b;
const square = x => x * x;
const makeUser = (name) => ({ name }); // parentheses for object literal

Lexical this (the biggest behavioral change)

In traditional functions, this depends on how the function is called. In arrow functions, this is captured from the surrounding scope.

Example: event handler-like pattern:

class Counter {
  constructor() {
    this.count = 0;
  }

  incLater() {
    setTimeout(() => {
      this.count += 1; // 'this' refers to Counter instance
    }, 10);
  }
}

If you used a normal function:

setTimeout(function () {
  this.count += 1; // 'this' is not the instance (often global/undefined)
}, 10);

You’d need bind or const self = this.

Arrow function pitfalls

  1. No arguments object
    Use rest parameters instead:

    const sum = (...nums) => nums.reduce((a, b) => a + b, 0);
  2. Not constructible
    You cannot use new with arrow functions:

    const Person = (name) => { this.name = name; };
    // new Person("X") // TypeError
  3. Can reduce readability when overused
    For complex logic, a block body with explicit return is often clearer.


Template Literals: Beyond String Interpolation

Template literals use backticks and support interpolation, multiline strings, and tagged templates.

Interpolation and multiline

const name = "Sam";
const msg = `Hello, ${name}!
This is on a new line.`;
console.log(msg);

Building strings safely

Template literals reduce concatenation noise:

const url = `https://api.example.com/users/${encodeURIComponent(name)}`;

Tagged templates (advanced but practical)

Tagged templates let you preprocess a template. Useful for escaping HTML or building SQL safely (with parameterization).

function escapeHTML(strings, ...values) {
  const escape = (s) =>
    String(s)
      .replaceAll("&", "&")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#039;");

  return strings.reduce((acc, str, i) => {
    const val = i < values.length ? escape(values[i]) : "";
    return acc + str + val;
  }, "");
}

const userInput = `<script>alert("xss")</script>`;
const safe = escapeHTML`<div>${userInput}</div>`;
console.log(safe);

Key idea: the tag function receives the literal parts and interpolated values separately, enabling safe transformations.


Destructuring: Objects, Arrays, Defaults, and Patterns

Destructuring lets you extract values into variables with concise syntax.

Object destructuring

const user = { id: 1, name: "Rae", role: "admin" };
const { id, name } = user;

Rename variables:

const { role: userRole } = user;

Default values:

const { plan = "free" } = user;

Nested destructuring:

const payload = { meta: { requestId: "abc" }, data: { ok: true } };
const {
  meta: { requestId },
  data: { ok },
} = payload;

Array destructuring

const rgb = [255, 100, 50];
const [r, g, b] = rgb;

Skip elements:

const [first, , third] = [1, 2, 3];

Rest element:

const [head, ...tail] = [1, 2, 3, 4];

Destructuring function parameters (use carefully)

function connect({ host, port = 5432 }) {
  return `${host}:${port}`;
}

This is powerful but can hurt debugging if overused. A common safe pattern is to provide a default object:

function connect({ host, port = 5432 } = {}) {
  if (!host) throw new Error("host required");
  return `${host}:${port}`;
}

Default Parameters and Rest/Spread

Default parameters

function greet(name = "friend") {
  return `Hello, ${name}`;
}

Defaults are evaluated at call time:

let i = 0;
function nextId(id = ++i) {
  return id;
}

Rest parameters

Rest collects remaining arguments into an array:

function logAll(prefix, ...items) {
  for (const item of items) console.log(prefix, item);
}

This replaces many uses of arguments.

Spread syntax

Spread expands arrays/iterables or object properties.

Array copy/merge:

const a = [1, 2];
const b = [3, 4];
const merged = [...a, ...b];

Avoid mutation patterns like push.apply.

Object spread (standardized after ES6 but commonly used with ES6 tooling; still important in modern ES6+ code):

const base = { a: 1, b: 2 };
const next = { ...base, b: 99, c: 3 };

Important nuance: object spread is shallow; nested objects are shared.


Enhanced Object Literals

ES6 improved object literal syntax.

Property shorthand

const name = "Lee";
const user = { name }; // same as { name: name }

Method shorthand

const api = {
  fetchUser(id) {
    return { id };
  },
};

Computed property names

const field = "status";
const obj = {
  [field]: "ok",
};

This is useful for dynamic keys without awkward intermediate steps.


Classes: Syntax Sugar with Real Consequences

ES6 classes provide a cleaner syntax over prototypal inheritance, but they still use prototypes under the hood.

Basic class

class User {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return `Hi, ${this.name}`;
  }
}

const u = new User("Kai");
console.log(u.greet());

Methods are placed on User.prototype, not recreated per instance.

Inheritance with extends and super

class Admin extends User {
  constructor(name, permissions) {
    super(name);
    this.permissions = permissions;
  }

  isAllowed(action) {
    return this.permissions.includes(action);
  }
}

Static methods

class Id {
  static next() {
    return crypto.randomUUID();
  }
}

Static methods are on the class itself, not instances.

Private fields (not ES6, but common in modern code)

Private fields use #. This is post-ES6, but you’ll see it in ES6-style class codebases:

class Vault {
  #secret;
  constructor(secret) {
    this.#secret = secret;
  }
  reveal() {
    return this.#secret;
  }
}

Practical class guidance


Modules: import/export in Practice

Modules solve global namespace pollution and make dependencies explicit.

Exporting

math.js:

export const add = (a, b) => a + b;
export const mul = (a, b) => a * b;

export default function square(x) {
  return x * x;
}

Importing

index.js:

import square, { add, mul } from "./math.js";

console.log(add(2, 3));
console.log(square(4));

Run:

node index.js

Named vs default exports

A common guideline: prefer named exports for libraries, default exports for single-purpose modules (or when a module “is” one thing).

Live bindings (important nuance)

ES modules export live bindings, not copies. If a module updates an exported variable, importers see the updated value.

state.js:

export let count = 0;
export function inc() {
  count += 1;
}

main.js:

import { count, inc } from "./state.js";
console.log(count); // 0
inc();
console.log(count); // 1

Promises: Composition and Error Handling

Promises represent eventual completion (or failure) of async operations.

Creating and using promises

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

delay(100).then(() => console.log("done"));

Promise chaining and return values

A .then() callback can return:

delay(50)
  .then(() => 42)
  .then((n) => n + 1)
  .then(console.log); // 43

Error handling: .catch() and propagation

delay(10)
  .then(() => {
    throw new Error("boom");
  })
  .catch((err) => {
    console.error("Caught:", err.message);
  });

A .catch() handles rejections from any previous step in the chain.

Promise.all vs Promise.allSettled

Promise.all fails fast:

const a = delay(10).then(() => "A");
const b = Promise.reject(new Error("B failed"));

Promise.all([a, b])
  .then(console.log)
  .catch((e) => console.error("all failed:", e.message));

Promise.allSettled waits for all results:

Promise.allSettled([a, b]).then(console.log);

Promise.race and Promise.any

These are not ES6 (they arrived later), but are common in modern async patterns.


Iterators and Generators

ES6 introduced iterators and generators, which power for...of, spread on iterables, and many library patterns.

Iterables and for...of

Arrays, strings, maps, sets are iterable:

for (const ch of "abc") {
  console.log(ch);
}

for...of uses the iterable protocol (Symbol.iterator).

Creating a custom iterable

const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const end = this.to;
    return {
      next() {
        if (current <= end) return { value: current++, done: false };
        return { value: undefined, done: true };
      },
    };
  },
};

console.log([...range]); // [1, 2, 3]

Generators: simpler iterator creation

Generators (function*) produce iterators with yield.

function* rangeGen(from, to) {
  for (let i = from; i <= to; i++) {
    yield i;
  }
}

console.log([...rangeGen(1, 3)]);

When generators shine:


Symbols and Well-Known Symbols

Symbols are unique identifiers, often used as non-colliding object keys.

const ID = Symbol("id");
const obj = { [ID]: 123 };
console.log(obj[ID]); // 123

Why symbols matter

Well-known symbols

JavaScript defines symbols that change language behavior. Example: Symbol.iterator makes an object iterable (as shown earlier). Another example: Symbol.toStringTag affects Object.prototype.toString.

const thing = {
  get [Symbol.toStringTag]() {
    return "Thing";
  },
};

console.log(Object.prototype.toString.call(thing)); // [object Thing]

Collections: Map, Set, WeakMap, WeakSet

ES6 introduced better data structures than plain objects for certain tasks.

Map: key-value with any key type

Objects coerce keys to strings; Map does not.

const m = new Map();
const keyObj = { k: 1 };

m.set(keyObj, "value");
console.log(m.get(keyObj)); // "value"

Iterate:

for (const [k, v] of m) {
  console.log(k, v);
}

Use Map when:

Set: unique values

const s = new Set([1, 2, 2, 3]);
console.log([...s]); // [1, 2, 3]

Common pattern: deduplicate an array:

const unique = [...new Set([1, 1, 2, 3, 3])];

WeakMap and WeakSet: garbage-collection friendly

Weak collections only allow object keys/values and do not prevent garbage collection. They are not enumerable.

A practical use: attach metadata to objects without risking memory leaks.

const meta = new WeakMap();

function track(obj, info) {
  meta.set(obj, info);
}

const o = {};
track(o, { seenAt: Date.now() });

When o becomes unreachable, its metadata can be collected too.


New Built-ins and Methods Worth Knowing

ES6 added several helpful methods and objects.

Object.assign

Shallow copy/merge:

const merged = Object.assign({}, { a: 1 }, { b: 2 });

Object spread often replaces it, but Object.assign is still useful in some environments.

Object.is

Like === but handles NaN and -0 differences:

Object.is(NaN, NaN); // true
Object.is(-0, 0);    // false

Array.from and Array.of

Convert array-like or iterable to array:

const arr = Array.from("hello"); // ["h","e","l","l","o"]

Create arrays reliably:

Array.of(3); // [3]
new Array(3); // empty array of length 3 (often not what you want)

find and findIndex

const users = [{ id: 1 }, { id: 2 }];
const u = users.find((x) => x.id === 2);

String methods

startsWith, endsWith, includes:

"hello".includes("ell"); // true

Transpiling and Tooling: Babel + ESLint

Even if modern runtimes support ES6, transpiling can help with:

Babel setup (practical commands)

Install Babel:

npm i -D @babel/core @babel/cli @babel/preset-env

Create a Babel config:

cat > babel.config.json <<'EOF'
{
  "presets": [
    ["@babel/preset-env", { "targets": "defaults" }]
  ]
}
EOF

Add scripts:

npm pkg set scripts.build="babel src -d dist"
npm pkg set scripts.start="node dist/index.js"

Create files:

mkdir -p src
cat > src/index.js <<'EOF'
const greet = (name = "world") => `Hello, ${name}`;
console.log(greet());
EOF

Build and run:

npm run build
npm start

ESLint for ES6 code quality

Install:

npm i -D eslint
npm init @eslint/config

Then run:

npx eslint src

Why linting matters for ES6 features: it catches misuse of const, accidental shadowing with let, confusing arrow function bodies, and more.


Practical Patterns and Guidance

This section ties features together into patterns you can use immediately.

1) Prefer pure functions + destructuring for clarity

Instead of passing a large object and manually reading fields:

function formatUser(user) {
  return user.name + " (" + user.role + ")";
}

Use destructuring:

function formatUser({ name, role }) {
  return `${name} (${role})`;
}

This documents dependencies at the function boundary.

2) Use Map for caches keyed by objects

A common anti-pattern is using object keys in plain objects:

const cache = {};
const key = {};
cache[key] = "x"; // becomes "[object Object]" key

Use Map:

const cache = new Map();
const key = {};
cache.set(key, "x");

3) Avoid “clever” arrow function one-liners in complex logic

Readable:

const normalize = (s) => {
  const trimmed = s.trim();
  return trimmed.toLowerCase();
};

Overly clever:

const normalize = s => s.trim().toLowerCase();

The one-liner is fine until logic grows. Refactor early.

4) Use modules to isolate side effects

Keep side effects (I/O, timers, global mutation) near the edges:

This makes testing and refactoring easier.

5) Promise composition: prefer returning promises over nesting

Avoid:

doA().then(() => {
  doB().then(() => {
    doC();
  });
});

Prefer:

doA()
  .then(() => doB())
  .then(() => doC())
  .catch(handleError);

Or use Promise.all for parallel work:

Promise.all([fetchUser(), fetchPermissions()])
  .then(([user, perms]) => ({ user, perms }))
  .then(render)
  .catch(handleError);

6) Use generators when you need lazy pipelines

Example: process a large list without allocating intermediate arrays:

function* filter(iterable, pred) {
  for (const x of iterable) if (pred(x)) yield x;
}

function* map(iterable, fn) {
  for (const x of iterable) yield fn(x);
}

const data = rangeGen(1, 1_000_000);
const pipeline = map(filter(data, (n) => n % 2 === 0), (n) => n * 2);

// pull only first 5 results
let i = 0;
for (const v of pipeline) {
  console.log(v);
  if (++i === 5) break;
}

This is a real performance and memory win in data-heavy scenarios.


Summary

ES6 is not just a bag of syntax upgrades; it changes how you structure programs:

If you want, share a small snippet from your current codebase (or a pattern you use often—state management, API clients, UI event handling), and I can show how to refactor it using ES6 features in a way that improves readability and correctness without becoming “too clever.”