ES6+ Features Explained — The Why, When, and Real-World How
ES6+ JavaScript features demystified — learn arrow functions, destructuring, async/await, and more with real-world patterns and interview-ready explanations..
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- ES6+ is a set of modern JavaScript syntax and features that reduce boilerplate and eliminate common bugs.
- Key features: arrow functions, let/const, destructuring, spread/rest, template literals, Promises, async/await, modules.
- Performance: arrow functions don't bind
this— saves memory per callback, but no performance difference in execution. - Production insight: Forgetting to handle Promise rejections crashes Node.js processes in newer versions.
- Biggest mistake: Using arrow functions as object methods breaks
this— always use regular function syntax for methods.
ES6+ (ECMAScript 2015 and its annual updates) is the modern foundation of JavaScript, introducing syntax and features that fundamentally shifted the language from a quirky scripting tool to a serious, maintainable language for large-scale applications. Before ES6, JavaScript relied on var for variable declaration (with its confusing function-scoping and hoisting), verbose function expressions, and manual iteration patterns that bred bugs.
ES6+ solved these pain points by adding block-scoped let and const, arrow functions with lexical this, destructuring for concise data extraction, and new data structures like Map and Set that handle real-world data more efficiently than plain objects or arrays. These aren't just syntactic sugar—they eliminate entire categories of runtime errors and reduce boilerplate by 30-50% in typical codebases, which is why every major framework (React, Vue, Angular) and toolchain (Babel, TypeScript, Webpack) adopted them immediately.
You should use ES6+ features in virtually every JavaScript project today, unless you're targeting legacy environments like Internet Explorer 11 without a transpiler. The let/const pattern alone prevents the infamous loop-closure bug that plagued var in asynchronous code.
Arrow functions make callbacks readable and eliminate the var self = this hack. Destructuring and spread operators turn complex state management (like Redux reducers or API response handling) into one-liners. The for...of loop gives you a unified way to iterate over arrays, strings, Maps, Sets, and custom iterables without worrying about indices or hasOwnProperty checks. Map and Set provide O(1) lookups and deduplication that plain objects can't match, making them the right choice for caches, frequency counters, and graph algorithms.
Where ES6+ falls short is in extremely memory-constrained environments (e.g., embedded systems) where the overhead of new object types or transpiled code matters, or when you need to maintain a legacy codebase that can't be refactored. For those cases, stick with ES5 patterns.
But for any modern web app, Node.js service, or mobile app using React Native, ES6+ is the baseline—not an option. The features in this article are the ones you'll use daily: they reduce cognitive load, catch bugs at compile time (with TypeScript or linting), and make your code self-documenting.
If you're still writing var and function everywhere, you're working harder, not smarter.
Imagine you used to pack for a trip by laying every single shirt, sock, and shoe out one by one, naming each item out loud before putting it in the bag. ES6+ is like getting a smart packing organizer — it lets you grab a whole outfit at once, label compartments automatically, and even pack for tomorrow's trip while you sleep. It doesn't change what you're doing (writing JavaScript), it just removes the tedious, error-prone busywork so you can focus on actually building things.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
JavaScript before ES6 was like cooking in a kitchen where you had to make every single utensil by hand before you could start the recipe. You could do it — millions of developers did — but an enormous chunk of your day was fighting ceremony instead of solving real problems. ES6 (released in 2015) and the yearly spec updates that followed (ES7, ES8... collectively called ES6+) rewired how modern JavaScript is written. Every production codebase you'll encounter today — React apps, Node APIs, browser extensions — is built on these features.
Why ES6+ Changed JavaScript Forever
ES6 (ECMAScript 2015) and its subsequent releases introduced a set of language features that fundamentally shifted JavaScript from a prototype-based scripting language to a modern, multi-paradigm programming language. The core mechanic is syntactic sugar over existing prototypes and patterns — arrow functions, classes, template literals, destructuring, spread/rest, and modules — that reduce boilerplate and enforce clearer intent. These features compile down to the same underlying mechanics but eliminate entire classes of bugs (e.g., this binding errors, accidental globals).
In practice, ES6+ features operate at compile-time (via transpilers like Babel) or natively in modern runtimes. Arrow functions lexically bind this, making callbacks predictable without .bind() or const self = this. const and let provide block scoping, replacing var's function-scoped hoisting. Destructuring and spread operators enable immutable data patterns in one line. Modules (import/export) give JavaScript a native dependency system, replacing IIFE-based module patterns and script-tag ordering nightmares.
Use ES6+ features in every new project targeting modern browsers or Node.js 8+. They reduce cognitive overhead, eliminate entire categories of runtime errors, and make code self-documenting. In production systems, adopting these features consistently (via lint rules and transpilation) cuts debugging time by 30-50% because intent is explicit and side effects are contained. The real win is not syntax — it's that these features enforce patterns that scale: immutability, lexical scoping, and module boundaries.
let creates a temporal dead zone, preventing access before declaration).const does not make objects immutable — only the binding. This leads to silent state mutations in Redux reducers or React state, causing hard-to-reproduce UI bugs. The rule: use const for bindings, but freeze or use Immutable.js for deeply immutable data.let & const vs var — Why Block Scope Changed Everything
Before ES6, var was the only way to declare a variable. The problem? var is function-scoped, not block-scoped. That means a variable declared inside an if block leaks out into the surrounding function. In large codebases this causes bugs that are genuinely hard to trace — you change a variable inside a loop, and suddenly something outside the loop has a different value than you expected.
let and const brought block scoping to JavaScript. A variable declared with let or const inside curly braces {} lives and dies inside those braces. Nothing outside can see it. const goes one step further — it prevents reassignment of the binding itself, which makes your intent clear: 'this value should not change.'
Use const by default. Reach for let only when you know you'll reassign (like a loop counter or an accumulator). Avoid var in new code entirely — there's no modern scenario where var is the better choice.
// ----- THE VAR PROBLEM ----- function calculateDiscount_OLD(price) { if (price > 100) { var discount = 20; // declared inside the if-block } // 'discount' leaks OUT of the if-block because var is function-scoped console.log('var discount outside block:', discount); // 20 (!) — or undefined if price <= 100 } calculateDiscount_OLD(150); // ----- THE let FIX ----- function calculateDiscount_NEW(price) { if (price > 100) { let discount = 20; // block-scoped — stays inside the if-block } // This line would throw: ReferenceError: discount is not defined // console.log(discount); // safely commented out — the error IS the feature! console.log('let prevents the accidental leak — discount is not visible here'); } calculateDiscount_NEW(150); // ----- const FOR VALUES THAT SHOULD NOT CHANGE ----- const TAX_RATE = 0.08; // Tax rate won't change during the program const itemPrice = 49.99; const totalPrice = itemPrice + itemPrice * TAX_RATE; console.log('Total with tax:', totalPrice.toFixed(2)); // 53.99 // TAX_RATE = 0.10; // Uncommenting this throws: TypeError: Assignment to constant variable // ----- IMPORTANT: const with objects ----- const userProfile = { name: 'Alice', role: 'admin' }; userProfile.role = 'editor'; // This IS allowed — we're mutating the object, not rebinding the variable console.log('Updated role:', userProfile.role); // editor // userProfile = {}; // THIS would throw — you can't rebind the const variable itself
Object.freeze() — but know that freeze is only one level deep.Object.freeze() only when you need deep immutabilityArrow Functions and Destructuring — Less Noise, More Signal
Arrow functions (=>) aren't just a shorter way to write a function — they deliberately don't bind their own this. In traditional functions, this depends on how the function is called, which is why you'd see code like var self = this; or .bind(this) everywhere. Arrow functions inherit this from the surrounding lexical scope, eliminating an entire class of confusing bugs.
Destructuring is the other daily-use feature that transforms how readable your code is. Instead of writing const userName = user.name; const userAge = user.age; on separate lines, you extract multiple values from an object or array in a single, expressive line. It reads almost like English: 'from this user object, give me the name and age.'
These two features combine constantly in real code — you'll see arrow functions as array callbacks and destructuring in function parameters. Learning them together is the fastest path to reading and writing modern JavaScript fluently.
// ----- THE `this` PROBLEM ARROW FUNCTIONS SOLVE ----- const timer = { message: 'Time is up!', // Old-style function: `this` depends on how the function is called startOld: function () { setTimeout(function () { // Inside a regular callback, `this` is no longer the timer object // In strict mode it's undefined; in browsers it's the window object console.log('Old way — this.message:', this.message); // undefined (or crash) }, 100); }, // Arrow function: `this` is inherited from startNew's scope (the timer object) startNew: function () { setTimeout(() => { console.log('Arrow way — this.message:', this.message); // 'Time is up!' }, 100); }, }; timer.startOld(); timer.startNew(); // ----- ARROW FUNCTIONS AS ARRAY CALLBACKS ----- const products = [ { name: 'Keyboard', price: 79 }, { name: 'Monitor', price: 299 }, { name: 'Mouse', price: 45 }, ]; // map with an arrow function — clean, one-liner transformation const productNames = products.map((product) => product.name); console.log('Product names:', productNames); // ['Keyboard', 'Monitor', 'Mouse'] // filter — only items under $100 const affordableProducts = products.filter((product) => product.price < 100); console.log('Under $100:', affordableProducts.map((p) => p.name)); // ['Keyboard', 'Mouse'] // ----- OBJECT DESTRUCTURING ----- const orderDetails = { orderId: 'ORD-8821', customer: 'Bob Martinez', total: 124.5, status: 'shipped', }; // Extract only what you need — notice the rename: status -> orderStatus const { orderId, customer, status: orderStatus } = orderDetails; console.log(`Order ${orderId} for ${customer} is ${orderStatus}`); // Order ORD-8821 for Bob Martinez is shipped // ----- ARRAY DESTRUCTURING ----- const [firstPlace, secondPlace, , fourthPlace] = ['Alice', 'Bob', 'Carol', 'Dave']; console.log('Winner:', firstPlace); // Alice console.log('Runner-up:', secondPlace); // Bob console.log('4th place:', fourthPlace); // Dave (skipped Carol with the empty comma) // ----- DESTRUCTURING IN FUNCTION PARAMETERS (very common in React) ----- function renderUserCard({ name, role = 'viewer', avatarUrl = '/default-avatar.png' }) { // Default values in destructuring mean we never get undefined for missing fields console.log(`Rendering card for ${name} (${role}) — avatar: ${avatarUrl}`); } renderUserCard({ name: 'Alice', role: 'admin' }); // Rendering card for Alice (admin) — avatar: /default-avatar.png renderUserCard({ name: 'Charlie' }); // Rendering card for Charlie (viewer) — avatar: /default-avatar.png
this, using them as methods directly on an object will cause this to point to the outer scope (often undefined in modules, or the global object in scripts) instead of the object itself. Always use regular function syntax for object methods and class methods. Arrow functions shine as callbacks and helper functions — not as the primary method definition.this.setState throws 'undefined' because this is the window or undefined. The fix: always use class method syntax or bind in constructor.this is undefined in a method, check the definition — arrow function? replace with function() {} or method shorthand.this lexically — great for callbacks, deadly for methodsSpread, Rest, and Template Literals — Clean Data Handling
The spread operator (...) lets you 'unpack' an array or object into individual pieces. Think of it like opening a box and laying everything out on a table. Its sibling, the rest parameter, does the opposite — it gathers a variable number of arguments into an array. Same syntax, opposite directions, and understanding both together prevents a lot of confusion.
These aren't just convenience features. Spread is the backbone of immutable data patterns in React and Redux — instead of mutating an existing object, you spread it into a new one with your changes. That one pattern is responsible for making component state predictable across thousands of React apps.
Template literals (backtick strings) replace string concatenation entirely. They support multiline strings without escape characters, and embedded expressions with ${} that can hold any JavaScript expression — not just variables.
// ----- SPREAD WITH ARRAYS ----- const northernCities = ['Oslo', 'Stockholm', 'Helsinki']; const southernCities = ['Rome', 'Athens', 'Madrid']; // Combine two arrays without mutation const allCities = [...northernCities, 'Paris', ...southernCities]; console.log('All cities:', allCities); // ['Oslo', 'Stockholm', 'Helsinki', 'Paris', 'Rome', 'Athens', 'Madrid'] // Clone an array (not a reference — a new array) const citiesCopy = [...northernCities]; citiesCopy.push('Reykjavik'); console.log('Original unchanged:', northernCities); // Oslo, Stockholm, Helsinki console.log('Clone has new city:', citiesCopy); // Oslo, Stockholm, Helsinki, Reykjavik // ----- SPREAD WITH OBJECTS (the React state update pattern) ----- const currentUserSettings = { theme: 'dark', language: 'en', notificationsEnabled: true, }; // Create a new settings object with just the theme changed // The spread copies all existing keys, then the last key 'wins' for overrides const updatedSettings = { ...currentUserSettings, theme: 'light' }; console.log('Original settings:', currentUserSettings.theme); // dark — untouched console.log('Updated settings:', updatedSettings.theme); // light // ----- REST PARAMETERS ----- function calculateShippingCost(baseRate, ...itemWeights) { // `itemWeights` collects all arguments after the first into a real array const totalWeight = itemWeights.reduce((sum, weight) => sum + weight, 0); const shippingCost = baseRate + totalWeight * 0.5; console.log(`Items: ${itemWeights.length}, Total weight: ${totalWeight}kg, Cost: $${shippingCost.toFixed(2)}`); } calculateShippingCost(5, 1.2, 0.8, 3.5); // 3 items // Items: 3, Total weight: 5.5kg, Cost: $7.75 calculateShippingCost(5, 0.5); // 1 item // Items: 1, Total weight: 0.5kg, Cost: $5.25 // ----- TEMPLATE LITERALS ----- const orderSummary = { id: 'ORD-4492', itemCount: 3, total: 87.49, deliveryDate: 'Thursday', }; // Multiline template literal — no more \n escape sequences const confirmationEmail = ` Hi there, Your order ${orderSummary.id} has been confirmed. You ordered ${orderSummary.itemCount} items for a total of $${orderSummary.total.toFixed(2)}. Expected delivery: ${orderSummary.deliveryDate}. Thank you for shopping with us! `.trim(); console.log(confirmationEmail); // Full email block with real line breaks — no string concatenation needed
structuredClone() for deep config copies or manually clone nested properties.arguments with a real arrayThe for...of Loop — Iterating Over Iterables
Before ES6, iterating over arrays required a for loop with an index, or the forEach method (which doesn't support break/continue/return). For objects, you'd use for...in, which iterates over enumerable property names including inherited ones, often causing bugs.
The for...of loop (ES6) solves this by working directly with iterables — arrays, strings, Maps, Sets, NodeLists, and any object implementing the iterable protocol. It gives you values, not indices, and supports break, continue, and return. It's the cleanest way to loop through built-in data structures.
Use for...of when you need the values of an array or any iterable. Use for...in only when you need object keys (and you're sure about property enumeration order).
// ----- for...of WITH ARRAYS ----- const colors = ['red', 'green', 'blue']; for (const color of colors) { console.log(color); } // red // green // blue // ----- for...of WITH STRINGS ----- const greeting = 'Hi!'; for (const char of greeting) { console.log(char); } // H // i // ! // ----- for...of WITH MAPS ----- const userMap = new Map([ ['name', 'Alice'], ['role', 'admin'], ]); for (const [key, value] of userMap) { console.log(`${key}: ${value}`); } // name: Alice // role: admin // ----- for...of WITH SETS ----- const uniqueIds = new Set([101, 102, 103, 101]); for (const id of uniqueIds) { console.log(id); } // 101 // 102 // 103 // ----- BREAK and CONTINUE WORK ----- const numbers = [1, 2, 3, 4, 5]; for (const n of numbers) { if (n === 3) continue; if (n === 5) break; console.log(n); } // 1 // 2 // 4 // ----- DO NOT CONFUSE with for...in ----- const obj = { a: 1, b: 2 }; for (const key in obj) { console.log(key, obj[key]); // 'a' 1, 'b' 2 — but not recommended for arrays } const arr = [10, 20, 30]; for (const index in arr) { console.log(index, arr[index]); // '0' 10, '1' 20, '2' 30 — uses index as string, includes inherited props }
forEach does not support break or continue — you'd need to throw an exception or use a return (which only exits the callback). for...of supports all loop control statements and is often more performant for large arrays because it avoids the overhead of a callback per iteration.for...in on arrays in production — it iterates over enumerable properties (including array indices as strings) and can pick up inherited enumerable properties from prototypes. Always use for...of for arrays. A common bug: iterating over a NodeList with for...in returns unexpected properties because the NodeList inherits from Object.prototype.for...of for iterables, for...in only for plain objects when you need keys.Map and Set — New Data Structures for Modern JavaScript
ES6 introduced two new built-in data structures: Map (key-value pairs where keys can be any type) and Set (unique values of any type). They fill gaps that plain objects and arrays left open.
Map vs Object: A Map preserves insertion order, performs better with frequent additions/removals, and accepts any value as a key (including objects, functions, NaN). Objects convert keys to strings (e.g., { 'true': 1 }), while Maps keep the original type.
Set vs Array: A Set automatically enforces uniqueness — no duplicate values. It provides .has(value) in O(1) time, which is much faster than Array.includes() (O(n)). Use Set when you need to track unique items and test membership.
WeakMap and WeakSet hold 'weak' references — they don't prevent garbage collection of keys. Use them when you need to associate data with objects without preventing their cleanup (e.g., caching DOM elements in a single-page app).
// ----- MAP: KEY-VALUE WITH ANY KEY TYPE ----- const userRoles = new Map(); userRoles.set(101, 'admin'); userRoles.set('user-102', 'editor'); userRoles.set({ id: 103 }, 'viewer'); // object as key console.log(userRoles.get(101)); // 'admin' console.log(userRoles.has('user-102')); // true console.log(userRoles.size); // 3 // Iteration preserves insertion order for (const [id, role] of userRoles) { console.log(`User ${id}: ${role}`); } // User 101: admin // User user-102: editor // User [object Object]: viewer // ----- SET: UNIQUE VALUES ----- const visitorIps = new Set(); visitorIps.add('192.168.1.1'); visitorIps.add('10.0.0.1'); visitorIps.add('192.168.1.1'); // duplicate ignored console.log(visitorIps.size); // 2 console.log(visitorIps.has('10.0.0.1')); // true // Convert Set to Array when needed const ipArray = [...visitorIps]; console.log(ipArray); // ['192.168.1.1', '10.0.0.1'] // ----- WEAKMAP: KEYS MUST BE OBJECTS, ALLOWS GC ----- const cache = new WeakMap(); let obj = { name: 'temp' }; cache.set(obj, 'cached data'); obj = null; // 'cached data' is now eligible for garbage collection // ----- WEAKSET: SIMILAR FOR SET ----- const activeUsers = new WeakSet(); let user = { id: 1 }; activeUsers.add(user); user = null; // user is removed from activeUsers when GC runs // ----- PRACTICAL USE: REMOVING DUPLICATES FROM AN ARRAY ----- const numbersWithDuplicates = [1, 2, 3, 2, 4, 1, 5]; const uniqueNumbers = [...new Set(numbersWithDuplicates)]; console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
size property (objects don't have a built-in size).for...in included prototype properties. Switching to Map fixed both issues and gave predictable performance.New String and Array Built-in Methods Reference Table
ES6+ added many practical methods to String and Array prototypes. Here's a quick reference for the most commonly used ones:
| Method | Category | Description | Example |
|---|---|---|---|
String.prototype.includes() | String | Returns true if string contains substring | 'Hello'.includes('ell') → true |
String.prototype.startsWith() | String | Checks if string starts with substring | 'file.js'.startsWith('file') → true |
String.prototype.endsWith() | String | Checks if string ends with substring | 'file.js'.endsWith('.js') → true |
String.prototype.repeat() | String | Returns new string repeated N times | 'ha'.repeat(3) → 'hahaha' |
Array.prototype.find() | Array | Returns first element that passes a test | [5,12,8,130].find(x => x > 10) → 12 |
Array.prototype.findIndex() | Array | Returns index of first passing element | [5,12,8,130].findIndex(x => x > 10) → 1 |
Array.prototype.fill() | Array | Fills elements with a static value | [1,2,3].fill(0, 0, 2) → [0, 0, 3] |
Array.prototype.includes() | Array | Checks if array contains a value | [1,2,3].includes(2) → true |
Array.prototype.keys() | Array | Returns iterator of indices | [...['a','b'].keys()] → [0,1] |
Array.prototype.values() | Array | Returns iterator of values | [...['a','b'].values()] → ['a','b'] |
Array.prototype.entries() | Array | Returns iterator of [index, value] pairs | [...['x','y'].entries()] → [[0,'x'],[1,'y']] |
Array.prototype.flat() | ES2019 | Flattens nested arrays to specified depth | [1,[2,[3]]].flat(2) → [1,2,3] |
Array.prototype.flatMap() | ES2019 | Maps then flattens result by one level | [1,2].flatMap(x => [x, x*10]) → [1,10,2,20] |
Use these methods instead of manual loops for cleaner, more declarative code. They are widely supported in modern environments and polyfills exist for legacy browsers.
// ----- STRING METHODS ----- const filename = 'photo_2024.jpg'; console.log(filename.startsWith('photo')); // true console.log(filename.endsWith('.jpg')); // true console.log(filename.includes('2024')); // true const separator = '---'.repeat(3); console.log(separator); // '---------' (9 dashes) // ----- ARRAY METHODS ----- const temperatures = [72, 85, 68, 90, 73]; // find first temperature over 80 const firstHot = temperatures.find(t => t > 80); console.log('First hot day:', firstHot); // 85 // find index of first over 85 const extremeIndex = temperatures.findIndex(t => t > 85); console.log('First extreme index:', extremeIndex); // 3 // check if any temperature is 90 console.log(temperatures.includes(90)); // true // fill with defaults (replace elements from index 1 to 3) const defaultTemps = [0, 0, 0, 0, 0]; defaultTemps.fill(72, 1, 3); console.log(defaultTemps); // [0, 72, 72, 0, 0] // keys, values, entries const colors = ['red', 'green', 'blue']; for (const index of colors.keys()) { console.log(index); // 0,1,2 } for (const value of colors.values()) { console.log(value); // 'red','green','blue' } for (const [i, v] of colors.entries()) { console.log(`${i}: ${v}`); } // 0: red, 1: green, 2: blue // ----- FLAT & FLATMAP (ES2019) ----- const nested = [1, [2, [3, [4]]]]; console.log(nested.flat(2)); // [1, 2, 3, [4]] const phrases = ['hello world', 'foo bar']; const words = phrases.flatMap(phrase => phrase.split(' ')); console.log(words); // ['hello', 'world', 'foo', 'bar']
flat and flatMap, support starts from Chrome 69, Firefox 62, Safari 12, Node 11. Use polyfills (like core-js) if targeting older environments.Array.includes() instead of indexOf() !== -1 improves readability and eliminates a common source of boolean confusion. Similarly, findIndex() is clearer than manually looping to find an index. These methods are well-optimized in modern V8; no performance penalty..includes(), .find(), and .findIndex() over manual loops or indexOf for clarity.ES5 vs ES6 Comparison Table for Each Major Feature
Here's a side-by-side reference of how the most important features changed from ES5 to ES6+. Use this table as a quick reminder when refactoring or reviewing code.
| Feature | ES5 Approach | ES6+ Approach |
|---|---|---|
| Variable declaration | var (function scope, hoisting) | let / const (block scope, TDZ) |
| Function syntax | everywhere | Arrow functions () => {} for callbacks; regular function for methods |
this in callbacks | var self = this; or .bind(this) | Arrow functions inherit this lexically |
| String concatenation | 'Hello ' + name + '!'; | Template literals: ` Hello ${name}! ` |
| Extracting values | var name = obj.name; var age = obj.age; | Destructuring: const { name, age } = obj; |
| Copying/merging objects | Object.assign({}, obj) | Spread: { ...obj } |
| Copying/merging arrays | Array.prototype.concat() or | Spread: [...arr] |
| Variable number of args | arguments object (array-like) | Rest parameters: ...args (real array) |
| Async code | Nested callbacks (callback hell) | Promises + async/await (linear flow) |
| Module system | <script> tags, IIFEs, globals | import / export (static, tree-shakable) |
| Iterating arrays | for (var i=0; i<arr.length; i++) or arr.forEach() | for...of loop (values, break/continue) |
| Data structures | Plain objects and arrays | Map, Set, WeakMap, WeakSet |
| String methods | indexOf() for substring check | .includes(), .startsWith(), .endsWith() |
| Array methods | Manual loops or indexOf | .find(), .findIndex(), .includes(), .flat(), .flatMap() |
This table doesn't cover every change, but it captures the most impactful shifts that you'll encounter daily.
// QUICK COMPARISON SNIPPETS: // 1. Variable scoping // ES5 var x = 1; if (true) { var x = 2; } // x is overwritten to 2 // ES6 let y = 1; if (true) { let y = 2; } // y remains 1 outside // 2. Function and this // ES5 var obj = { name: 'Alice', greet: function() { var self = this; setTimeout(function() { console.log('Hi ' + self.name); }, 100); } }; // ES6 var obj = { name: 'Alice', greet: function() { setTimeout(() => console.log(`Hi ${this.name}`), 100); } }; // 3. String interpolation // ES5: 'Welcome ' + user.name + ', you have ' + count + ' items.' // ES6: `Welcome ${user.name}, you have ${count} items.` // 4. Extracting values // ES5: var name = user.name; var role = user.role; // ES6: const { name, role } = user; // 5. Copying arrays // ES5: var copy = original.slice(); // ES6: const copy = [...original];
var with const/let, convert callback nesting to Promises/async/await, switch string concatenation to template literals, and introduce destructuring and spread. The table gives you a clear 'before and after' for each change.Promises — Callback Hell Had a Good Run
Before ES6, asynchronous JavaScript was a pyramid of doom. Callbacks nested inside callbacks nested inside callbacks. Readable? No. Maintainable? Hell no. Debugging a three-level deep callback chain in production taught me things about patience I never wanted to learn.
Promises flattened that mess. They gave us .then() and .catch() — clean chains that actually trace back to where the error happened. No more guessing which callback swallowed your exception. A Promise is just an object that represents a value that might be available now, later, or never. Three states: pending, fulfilled, rejected. That's it.
The real win? Error handling becomes predictable. One .catch() at the end of your chain catches everything — network failures, parse errors, logic bombs. Try doing that with callbacks without littering try/catch everywhere like confetti at a parade.
Yes, async/await came later and made Promises feel like synchronous code. But under the hood, it's still Promises doing the heavy lifting. If you don't understand Promises, you don't understand modern JavaScript.
// io.thecodeforge — javascript tutorial function fetchUserProfile(userId) { return fetch(`https://api.example.com/users/${userId}`) .then(response => { if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } return response.json(); }) .then(data => { return { id: data.id, name: `${data.firstName} ${data.lastName}`, email: data.email }; }) .catch(error => { console.error('Profile fetch failed:', error.message); throw error; // re-throw to let caller handle }); } fetchUserProfile(42) .then(profile => console.log(profile)) .catch(err => console.error('Final handler:', err));
ES6 Modules — Stop Leaking Variables Into Global Scope
Before import and export, JavaScript had one namespace: window. Every script you loaded dumped its variables into global scope. Remember that fun moment when two libraries both defined $? Welcome to the "my page is broken and I don't know why" club.
ES6 modules fixed that by making every file its own scope. You explicitly decide what escapes and what stays private. export makes something available. import pulls it in. No more script tag ordering spaghetti, no more global pollution.
Modules also solved the dependency problem. No more guessing which script to load first. The module system resolves dependencies for you. You import what you need, and the engine figures out the order.
Here's the catch: modules are strict by default. No implicit globals. this inside a module is undefined, not window. That's broken a lot of legacy code on upgrade day. But it's the right thing — your code shouldn't depend on the execution context being the global object.
Use named exports for libraries, default exports for single-value modules. Keep it consistent. Your future self — and your teammates — will thank you.
// io.thecodeforge — javascript tutorial // cart.js - module file export const TAX_RATE = 0.08; export function calculateTotal(items) { return items.reduce((sum, item) => sum + item.price * item.quantity, 0); } export function applyTax(subtotal) { return subtotal * (1 + TAX_RATE); } // Default export - single function function formatCurrency(amount) { return `$${amount.toFixed(2)}`; } export default formatCurrency; // main.js - importing module import formatCurrency, { calculateTotal, applyTax } from './cart.js'; const cartItems = [ { name: 'Monitor', price: 299.99, quantity: 2 }, { name: 'Keyboard', price: 89.99, quantity: 1 } ]; const subtotal = calculateTotal(cartItems); const total = applyTax(subtotal); console.log(formatCurrency(total));
Proxies and Reflect — Intercept Everything, Blame Nothing
Most devs skip this section. They shouldn't. Proxies give you superpowers: intercept property access, method calls, even delete operations. Think of it as middleware for your objects. Vue.js and MobX built reactive systems on this. You can too.
A Proxy wraps an object with traps — handler functions that fire when you read a property (get), set one (set), or delete it (deleteProperty). You control what happens. Validation, logging, lazy loading, computed properties — it's all on the table.
The Reflect API is the counterpart. It provides the default behaviors for those operations. Instead of calling obj[key] directly, you call Reflect.get(obj, key). This lets you apply default behavior inside your traps without calling the original object directly (which would retrigger the trap — infinite recursion, anyone?).
Real talk: don't overuse Proxies. They're slower than direct property access. But for framework code, validation layers, or API gateways? Indispensable. Just don't wrap every object in your hot loop. Your users will feel the lag.
Pair Proxies with Reflect and you can build patterns that feel like magic — until you have to debug them. Then you'll wish you'd left more comments.
// io.thecodeforge — javascript tutorial const userValidator = { set(target, property, value) { if (property === 'age') { if (typeof value !== 'number' || value < 0 || value > 150) { throw new TypeError(`Invalid age: ${value}`); } } if (property === 'email') { if (!value.includes('@')) { throw new TypeError(`Invalid email: ${value}`); } } return Reflect.set(target, property, value); }, get(target, property) { console.log(`Accessing '${property}': ${target[property]}`); return Reflect.get(target, property); } }; const user = new Proxy({}, userValidator); user.name = 'Alice'; user.age = 30; // OK user.email = 'alice@example.com'; // OK // user.age = -5; // Throws: Invalid age: -5 // user.email = 'bademail'; // Throws: Invalid email: bademail console.log(user.age);
Iterators & Generators — Lazy Sequences on Demand
Iterators let you define custom iteration behavior for any object using a method returning next(){value, done}. Before ES6, loops only worked on arrays; now any object can be iterable by implementing Symbol.iterator. Generators (function*) simplify iterator creation: they pause execution with yield and resume on demand, producing values lazily. This spares memory for huge datasets or infinite sequences (e.g., Fibonacci). Why it matters: You avoid loading everything into memory at once. Use generators for streaming, pagination, or async data flows where each step waits until called. Always remember: an iterator is a one-time pass — calling after next()done: true keeps returning undefined.
// io.thecodeforge — javascript tutorial function* fibonacci() { let a = 0, b = 1; while (true) { yield a; [a, b] = [b, a + b]; } } const fib = fibonacci(); console.log(fib.next().value); // 0 console.log(fib.next().value); // 1 console.log(fib.next().value); // 1 console.log(fib.next().value); // 2
fibonacci() creates a fresh iterator; sharing one across concurrent consumers causes missed values.Symbol — The Hidden Property Key
Symbol is a primitive type introduced in ES6 that creates unique, immutable identifiers. Every Symbol() call returns a new value, even with the same description. Why it matters: Symbols prevent name collisions in objects—critical when adding metadata or custom behaviors to foreign libraries. They also enable private-ish properties (no direct string access) and power built-in protocols like Symbol.iterator. Use Symbol.for('key') to share symbols globally across realms. A key pitfall: Symbols are not enumerable in for...in loops or Object.keys(), but they appear in Object.getOwnPropertySymbols(). Always check both string and symbol keys when cloning or merging.
// io.thecodeforge — javascript tutorial const RED = Symbol('color'); const BLUE = Symbol('color'); const car = { [RED]: '#ff0000', [BLUE]: '#0000ff', year: 2023 }; console.log(RED === BLUE); // false console.log(Object.keys(car)); // ['year'] console.log(Object.getOwnPropertySymbols(car)); // [ Symbol(color), Symbol(color) ]
Symbol() are not serializable. JSON.stringify drops them silently, causing data loss if you rely on them for state persistence.Arrow Function in Event Listener Crashes Live User Profile
this.submit being undefined.button.addEventListener('click', () => this.submitForm()) inside a class component.this; they inherit from the enclosing scope. Inside a class method, the enclosing scope is the class instance, but when the arrow function is passed as a callback to addEventListener, the this inside the arrow function still refers to the class instance (correct). However, in this case, the arrow function was defined in a constructor, but the this inside the constructor's arrow callback actually correctly refers to the instance. The bug was different: the developer used an arrow function as an object method directly: const handler = { click: () => this.submitForm() } and then passed handler.click. The arrow function's this was the global object (or undefined in strict mode) because it was not called on an object. The root cause: arrow functions are not suitable for object methods.click: function() { this.submitForm(); } or use method shorthand click() { this.submitForm(); }. The event listener then worked because this was correctly bound to the object.- Never use arrow functions as object methods or when you need dynamic
thisbinding (e.g., event listeners on DOM elements where you wantthisto be the element). - Use arrow functions for callbacks where you want to capture the surrounding
this(e.g., in class methods passed to setTimeout). - If you see 'undefined' where an object method should be, suspect arrow function misuse first.
this — if used as a method on an object literal, this is the outer scope. Replace with regular function or method shorthand..catch() to all Promise chains or wrap await calls in try/catch. If using top-level await in Node, use process.on('unhandledRejection', handler) as fallback.const result = asyncFunction(), you get a Promise. You must await it or use .then().console.log(this) inside the method to confirm scope.Change arrow function to method shorthand: `{ method() { ... } }`{ method: () => { ... } } with { method() { ... } } or { method: function() { ... } }grep for .catch or try/catch in the failing route handler.Run with `--unhandled-rejections=strict` flag to break at the rejection.try { await riskyFunction(); } catch (e) { handleError(e); }console.log(JSON.stringify(original, null, 2)) and JSON.stringify(copy, null, 2) to compare.Use `structuredClone(original)` or `JSON.parse(JSON.stringify(original))` for deep copy.structuredClone() with a fallback for older environments: const clone = structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));| Feature | Old Approach (ES5) | ES6+ Approach |
|---|---|---|
| Variable declaration | var (function-scoped, hoisted) | let / const (block-scoped, predictable) |
| Function syntax | function keyword everywhere | Arrow functions for callbacks; regular functions for methods |
| String building | "Hello " + name + ", you have " + count + " messages" | Hello ${name}, you have ${count} messages |
| Extracting object values | var name = user.name; var age = user.age; | const { name, age } = user; |
| Combining arrays/objects | Array.prototype.concat(), Object.assign() | Spread operator: [...arr], {...obj} |
| Async code | Nested callbacks (callback hell) | Promises + async/await (reads top-to-bottom) |
| Variable number of args | arguments object (array-like, not real array) | Rest parameters: ...args (a real array) |
| this in callbacks | var self = this; or .bind(this) | Arrow functions inherit this lexically |
| Module system | <script> tags, IIFEs, global variables | import / export (static, tree-shakable) |
| File | Command / Code | Purpose |
|---|---|---|
| blockScope.js | function calculateDiscount_OLD(price) { | let & const vs var |
| arrowAndDestructure.js | const timer = { | Arrow Functions and Destructuring |
| spreadRestTemplates.js | const northernCities = ['Oslo', 'Stockholm', 'Helsinki']; | Spread, Rest, and Template Literals |
| forOf.js | const colors = ['red', 'green', 'blue']; | The for...of Loop |
| mapSet.js | const userRoles = new Map(); | Map and Set |
| stringArrayMethods.js | const filename = 'photo_2024.jpg'; | New String and Array Built-in Methods Reference Table |
| es5vsEs6Examples.js | var x = 1; | ES5 vs ES6 Comparison Table for Each Major Feature |
| UserProfileFetch.js | function fetchUserProfile(userId) { | Promises |
| CartModule.js | export const TAX_RATE = 0.08; | ES6 Modules |
| ObjectValidator.js | const userValidator = { | Proxies and Reflect |
| fibonacciGenerator.js | function* fibonacci() { | Iterators & Generators |
| symbolExample.js | const RED = Symbol('color'); | Symbol |
Key takeaways
const by default and let only when reassignment is genuinely neededvar should never appear in code written after 2015.this binding problem in callbacks, but they are not a universal replacement for function...) creates shallow copies onlytry/catch, and sequential await inside a loop is a performance trap that Promise.all solves.import/export) give you real scoping and eliminate global namespace pollutionCommon mistakes to avoid
6 patternsUsing arrow function as object method
this is undefined inside the method. Method call returns Cannot read property '...' of undefined.{ method() { ... } } instead of { method: () => { ... } }.Assuming `const` makes objects immutable
Object.freeze() for shallow immutability, or Object.freeze() recursively for deep freeze. Use structuredClone() for deep copying.Forgetting `await` inside async function
await when calling an async function inside another async function. Or use .then() if not in async context.Using `await` in a loop sequentially when requests are independent
await with Promise.all(): const results = await Promise.all(tasks.map(task => fetch(task)))Confusing rest parameters with the `arguments` object
arguments in an arrow function throws ReferenceError because arrow functions don't have arguments. Or using arguments where rest parameters would be cleaner....args) when you need a variable number of arguments. Rest parameters are a real array. Add 'use strict' only if needed - modern modules are strict by default.Not understanding that spread creates shallow copies
structuredClone() (modern browsers/Node 17+) or JSON.parse(JSON.stringify(obj)) (with caveats). For one-level objects, spread is fine.Interview Questions on This Topic
What is the difference between `let`, `const`, and `var`? Can you describe a bug that `var` can cause that `let` would prevent?
var is function-scoped and hoisted, meaning it can be accessed before declaration (value is undefined). let and const are block-scoped and not initialized before declaration (Temporal Dead Zone). const additionally prevents reassignment of the binding, but not mutation of the value.
Example bug with var: In a for loop, if you create closures inside the loop, each closure references the same var variable (due to hoisting), so after the loop, all closures see the final value. With let, each iteration creates a new binding, so closures work correctly.
``javascript for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // prints 3,3,3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // prints 0,1,2
}
``Explain why you can't use an arrow function as a constructor (i.e., with `new`), and give an example of when you should NOT use an arrow function.
[[Construct]] internal method, so calling new on an arrow function throws a TypeError. They also don't have a prototype property. This is because arrow functions are designed to be lightweight and don't have their own this — they inherit from the enclosing scope.
When NOT to use arrow functions:
- As object methods: const obj = { method: () => { this.x = 1 } } — this is not obj.
- When you need dynamic this (e.g., event handlers where this should be the element).
- When you need to use arguments, super, or new.target (arrow functions don't have these).
Use regular functions in those cases.What is the difference between `Promise.all()` and `Promise.allSettled()`? If one of three parallel API requests fails, how does each one behave — and when would you prefer one over the other?
Promise.all() rejects immediately if any of the input Promises reject. The rejection value is the error from the first failing Promise. If you need all results regardless of failures, use Promise.allSettled().
Promise.allSettled() waits for all Promises to settle (either fulfilled or rejected). It returns an array of objects with status ('fulfilled' or 'rejected'), value (if fulfilled), or reason (if rejected).
Scenario: Three API calls: one fails, two succeed.
- Promise.all() rejects immediately with the error from the failed call. The other two may still complete, but their results are lost.
- Promise.allSettled() returns after all three complete, giving you the error for one and the values for the other two.
When to use which: Use Promise.all() when any failure should cause the whole operation to fail (e.g., fetching dependent data). Use Promise.allSettled() when you want to handle partial success (e.g., loading user profile details — some optional sections may fail, but you still show what succeeded).Frequently Asked Questions
Yes — ES6+ isn't optional in modern JavaScript development. React's component model relies heavily on destructuring, spread, arrow functions, and modules. Node.js uses async/await for nearly all I/O operations. Trying to read or write React or Node code without ES6+ knowledge is like trying to read a book with half the vocabulary missing.
=== (strict equality) checks both value AND type with no coercion — '5' === 5 is false. == (loose equality) coerces types first — '5' == 5 is true, which causes subtle bugs. Strict equality existed before ES6, but the ES6+ era solidified the community norm of always using ===. You should use === in all new code.
In practice, no — modern JavaScript engines (V8, SpiderMonkey) optimize both to equivalent machine code. Choose between them based on the this binding behavior and readability, not performance. The only micro-performance consideration is that arrow functions cannot be used as constructors, so the engine skips allocating a prototype — but this is irrelevant unless you're benchmarking millions of instantiations per second.
Start by adding a transpiler like Babel with a build tool (Webpack, Vite). Then incrementally refactor: replace var with const/let, change function expressions to arrow functions where appropriate, use destructuring in function parameters, and convert callbacks to Promises/async/await. Use linting rules like no-var to enforce consistency. Don't refactor everything at once — focus on areas that are most error-prone or difficult to maintain.
import { foo } imports a named export named foo from the module. import foo imports the default export from the module, giving it the local name foo. A module can have one default export and many named exports. Named exports must be imported with exactly the same name (unless you alias with as), while default imports can be named anything.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's Advanced JS. Mark it forged?
9 min read · try the examples if you haven't