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
- Prerequisites and Setup
letandconst: Block Scope and Immutability- Arrow Functions: Syntax,
this, and Pitfalls - Template Literals: Beyond String Interpolation
- Destructuring: Objects, Arrays, Defaults, and Patterns
- Default Parameters and Rest/Spread
- Enhanced Object Literals
- Classes: Syntax Sugar with Real Consequences
- Modules:
import/exportin Practice - Promises: Composition and Error Handling
- Iterators and Generators
- Symbols and Well-Known Symbols
- Collections:
Map,Set,WeakMap,WeakSet - New Built-ins and Methods Worth Knowing
- Transpiling and Tooling: Babel + ESLint
- 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:
"type": "module"inpackage.json, or.mjsfile 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:
- Use
constby default. - Use
letwhen reassignment is required. - Avoid
varin modern codebases.
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
-
No
argumentsobject
Use rest parameters instead:const sum = (...nums) => nums.reduce((a, b) => a + b, 0); -
Not constructible
You cannot usenewwith arrow functions:const Person = (name) => { this.name = name; }; // new Person("X") // TypeError -
Can reduce readability when overused
For complex logic, a block body with explicitreturnis 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("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
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
- Prefer composition over deep inheritance chains.
- Keep constructors lightweight.
- Avoid storing derived data on instances when it can be computed.
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
- Named exports are explicit and refactor-friendly (renames are caught).
- Default exports are convenient but can lead to inconsistent import naming.
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:
- a value (wrapped into a resolved promise)
- a promise (flattened)
- throw an error (becomes rejection)
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
race: first settled (resolve or reject) wins.any: first fulfilled wins (rejects only if all reject).
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:
- lazy sequences (don’t allocate full arrays)
- streaming transformations
- implementing complex iteration logic cleanly
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
- Avoid naming collisions in large codebases or when mixing libraries.
- Create “semi-private” properties (not truly private, but less discoverable).
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:
- keys are not strings (objects/functions)
- you need frequent add/remove and iteration
- you care about insertion order (Map preserves it)
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:
- targeting older browsers
- enabling newer syntax consistently
- applying polyfills as needed
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:
lib/exports pure helpersindex.jswires dependencies and triggers execution
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:
let/constenforce safer scoping and intent.- Arrow functions simplify callbacks and fix many
thisheadaches, but have tradeoffs. - Template literals and destructuring make code more expressive and reduce boilerplate.
- Rest/spread improves function signatures and immutable patterns.
- Classes and modules provide structure, but you still need to understand underlying behavior.
- Promises, iterators, generators, symbols, and new collections unlock more robust abstractions.
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.”