
JavaScript ES6 in Depth: A Complete Guide with Real-World Examples
JavaScript ES6 (ECMAScript 2015) introduced several powerful features that enhanced the language's readability, maintainability, and efficiency. Whether you're a beginner or an experienced developer, understanding these features can significantly improve your coding skills. In this blog, we will explore the most important ES6 features with code examples.
1. let and const - Block-Scoped Variables & var
In JavaScript, let, const, and var are used to declare variables, but they behave differently in terms of scope, hoisting, and mutability.
A). var (Function-scoped, hoisted, reassignable)
- Declares a variable globally or within a function scope.
- Variables declared with var are hoisted (moved to the top of their scope).
- Can be reassigned and redeclared.
console.log(a); // undefined (hoisted) var a = 10; console.log(a); // 10 function test() { var b = 20; console.log(b); } test(); console.log(b); // ReferenceError: b is not defined (function-scoped)
B). let (Block-scoped, not hoisted, reassignable)
- Introduced in ES6, let has block scope (confined within {}).
- Unlike var, it is not hoisted to the top.
- Can be reassigned but not redeclared within the same scope.
console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 5; console.log(x); // 5 if (true) { let y = 10; console.log(y); // 10 } console.log(y); // ReferenceError: y is not defined (block-scoped)
C). const (Block-scoped, not hoisted, immutable reference)
- Like let, const is block-scoped.
- Must be initialized when declared.
- The variable cannot be reassigned, but if it’s an object or array, its properties can be modified.
const z = 100; z = 200; // TypeError: Assignment to constant variable. const obj = { name: "John" }; obj.name = "Doe"; // Allowed: Object properties can change console.log(obj); // { name: "Doe" } // obj = {}; // TypeError: Assignment to constant variable
Key Differences Between var, let, and const:
- Scope:
- var → Function-scoped
- let → Block-scoped ({})
- const → Block-scoped ({})
- Hoisting:
- var → Hoisted with undefined initialization
- let → Hoisted but not initialized (throws error if accessed before declaration)
- const → Hoisted but not initialized (throws error if accessed before declaration)
- Redeclaration:
- var → Allowed
- let → Not allowed in the same scope
- const → Not allowed in the same scope
- Reassignment:
- var → Allowed
- let → Allowed
- const → Not allowed
- Initialization:
- var → Optional
- let → Optional
- const → Required at declaration
- Mutability:
- var → Can be changed
- let → Can be changed
- const → Cannot be reassigned, but objects/arrays can be modified
Best Practices:
✔ Use const by default unless you need to reassign the variable.
✔ Use let when reassignment is necessary.
❌ Avoid var due to hoisting issues and function scope limitations.
2. Arrow Functions - Shorter Function Syntax
Arrow functions (=>) provide a shorter and more concise syntax for writing functions in JavaScript. They were introduced in ES6 (ECMAScript 2015) and are particularly useful for writing anonymous functions.
A). Basic Syntax
Regular Function:
function add(a, b) { return a + b; } console.log(add(2, 3)); // Output: 5
Arrow Function Equivalent:
const add = (a, b) => a + b; console.log(add(2, 3)); // Output: 5
✅ No function keyword required
✅ Implicit return (if only one expression)
B). Arrow Function Variations
Single Parameter (No Parentheses Required)
const square = x => x * x; console.log(square(4)); // Output: 16
Multiple Parameters (Parentheses Required)
const multiply = (a, b) => a * b; console.log(multiply(3, 5)); // Output: 15
No Parameters (Requires Empty Parentheses)
const greet = () => "Hello, world!"; console.log(greet()); // Output: Hello, world!
Multi-Line Arrow Functions (With {} and return)
const subtract = (a, b) => { console.log(`Subtracting ${b} from ${a}`); return a - b; }; console.log(subtract(10, 4)); // Output: 6
C). Key Differences Between Arrow Functions & Regular Functions
- this Behavior
- Regular functions → this refers to the calling object.
- Arrow functions → this is lexically bound (inherits from the surrounding scope).
Example:
const obj = { value: 10, regularFunction: function () { console.log(this.value); // Refers to obj }, arrowFunction: () => { console.log(this.value); // `this` is inherited from outer scope (not obj) } }; obj.regularFunction(); // Output: 10 obj.arrowFunction(); // Output: undefined (or error in strict mode)
- Cannot be Used as Constructors
- Regular functions can be used with new, arrow functions cannot.
const Person = (name) => { this.name = name; }; const p = new Person("John"); // ❌ TypeError: Person is not a constructor
- No arguments Object
- Arrow functions do not have their own arguments object.
function regularFunc() { console.log(arguments); // ✅ Works } const arrowFunc = () => { console.log(arguments); // ❌ ReferenceError }; regularFunc(1, 2, 3); arrowFunc(1, 2, 3); // Error
D). When to Use Arrow Functions?
✔ For concise, short functions
✔ For callbacks (e.g., .map(), .filter(), .reduce())
✔ For preserving this in event handlers
Example: Using Arrow Functions in Callbacks
const numbers = [1, 2, 3, 4]; const doubled = numbers.map(n => n * 2); console.log(doubled); // Output: [2, 4, 6, 8]
✔ Arrow functions provide a cleaner, shorter syntax for writing functions.
✔ They don’t have their own this or arguments, making them useful in certain cases.
✔ Use them for simple operations but prefer regular functions for methods and constructors.
3. Template Literals - Better String Interpolation
Template literals allow embedding expressions within strings using backticks (`) instead of concatenation.
Example:
const name = "Alice"; const age = 25; console.log(`My name is ${name} and I am ${age} years old.`);
Key Takeaways:
- Use backticks ` instead of quotes.
- Embed expressions using ${}.
4. Destructuring Assignment - Easier Data Extraction
Destructuring Assignment is a feature in JavaScript that allows you to extract values from arrays or objects and assign them to variables in a concise way. It makes working with structured data cleaner and more readable.
A). Array Destructuring
Basic Syntax
const numbers = [10, 20, 30]; const [a, b, c] = numbers; console.log(a); // Output: 10 console.log(b); // Output: 20 console.log(c); // Output: 30
✅ No need for numbers[0], numbers[1], etc.
Skipping Elements
const nums = [1, 2, 3, 4, 5]; const [first, , third] = nums; console.log(first); // Output: 1 console.log(third); // Output: 3
✅ Skipping elements is easy using commas (,)
Using the Rest Operator (...)
const [first, second, ...rest] = [10, 20, 30, 40, 50]; console.log(first); // Output: 10 console.log(second); // Output: 20 console.log(rest); // Output: [30, 40, 50]
✅ Extracts remaining elements into an array (rest)
Swapping Variables Without Temporary Variables
let x = 5, y = 10; [x, y] = [y, x]; console.log(x); // Output: 10 console.log(y); // Output: 5
✅ Swaps values without needing a temp variable
B). Object Destructuring
Basic Syntax
const person = { name: "Alice", age: 25, city: "New York" }; const { name, age, city } = person; console.log(name); // Output: Alice console.log(age); // Output: 25 console.log(city); // Output: New York
✅ Extracts object properties easily without person.name, person.age, etc.
Renaming Variables
const user = { firstName: "John", lastName: "Doe" }; const { firstName: fName, lastName: lName } = user; console.log(fName); // Output: John console.log(lName); // Output: Doe
✅ Allows renaming properties while destructuring
Setting Default Values
const product = { title: "Laptop", price: 1000 }; const { title, price, stock = 10 } = product; console.log(stock); // Output: 10 (default value used)
✅ If stock is undefined, the default value (10) is used
C). Nested Destructuring
Extracting Values from Nested Objects
const employee = { name: "David", details: { position: "Developer", experience: 5 } }; const { name, details: { position, experience } } = employee; console.log(position); // Output: Developer console.log(experience); // Output: 5
✅ Easily access nested object properties
Extracting Values from Nested Arrays
const colors = [["red", "green"], ["blue", "yellow"]]; const [[primary1, primary2], [secondary1, secondary2]] = colors; console.log(primary1); // Output: red console.log(secondary2); // Output: yellow
✅ Works for arrays of arrays
D). Destructuring in Function Parameters
Passing Objects to Functions
function printUser({ name, age }) { console.log(`Name: ${name}, Age: ${age}`); } const user = { name: "Emma", age: 30 }; printUser(user); // Output: Name: Emma, Age: 30
✅ Extracts properties directly inside function parameters
Passing Arrays to Functions
function sum([a, b]) { return a + b; } console.log(sum([5, 10])); // Output: 15
✅ Extracts values from arrays directly in function parameters
✔ Destructuring makes data extraction easy and readable.
✔ Works for both arrays and objects.
✔ Supports renaming, default values, and nested destructuring.
✔ Useful in function parameters for clean code.
5. Spread and Rest Operators
The Spread (...) and Rest (...) operators in JavaScript were introduced in ES6 and are powerful tools for handling arrays, objects, and function arguments.
5.1. The Spread Operator (...)
The spread operator expands elements of an array, object, or iterable into individual elements.
A) Copying Arrays
const numbers = [1, 2, 3]; const newNumbers = [...numbers]; // Creates a new array copy console.log(newNumbers); // Output: [1, 2, 3]
✅ Creates a shallow copy of an array (avoids modifying the original array).
B) Merging Arrays
const arr1 = [1, 2, 3]; const arr2 = [4, 5, 6]; const mergedArray = [...arr1, ...arr2]; console.log(mergedArray); // Output: [1, 2, 3, 4, 5, 6]
✅ Concatenates arrays easily without using .concat().
C) Spreading Elements in Function Arguments
const numbers = [10, 20, 30]; function sum(a, b, c) { return a + b + c; } console.log(sum(...numbers)); // Output: 60
✅ Expands array elements as function arguments.
D) Copying and Merging Objects
const person = { name: "Alice", age: 25 }; const copiedPerson = { ...person }; // Creates a copy console.log(copiedPerson); // Output: { name: "Alice", age: 25 }
✅ Creates a shallow copy of an object (without affecting the original).
const user = { name: "John", age: 30 }; const additionalInfo = { city: "New York", country: "USA" }; const mergedUser = { ...user, ...additionalInfo }; console.log(mergedUser); // Output: { name: "John", age: 30, city: "New York", country: "USA" }
✅ Merges two or more objects efficiently.
E) Overriding Object Properties
const user = { name: "Emma", age: 28 }; const updatedUser = { ...user, age: 30 }; // Overrides age console.log(updatedUser); // Output: { name: "Emma", age: 30 }
✅ Later properties override earlier ones.
5.2. The Rest Operator (...)
The rest operator collects multiple elements into an array or groups object properties.
A) Function Parameters (Rest Arguments)
function sum(...numbers) { return numbers.reduce((total, num) => total + num, 0); } console.log(sum(1, 2, 3, 4, 5)); // Output: 15
✅ Gathers function arguments into an array.
B) Destructuring Arrays
const [first, second, ...rest] = [10, 20, 30, 40, 50]; console.log(first); // Output: 10 console.log(second); // Output: 20 console.log(rest); // Output: [30, 40, 50]
✅ Captures remaining values into an array.
C) Destructuring Objects
const user = { name: "Alice", age: 25, city: "New York", country: "USA" }; const { name, ...details } = user; console.log(name); // Output: Alice console.log(details); // Output: { age: 25, city: "New York", country: "USA" }
✅ Extracts a subset of an object while keeping the rest.
D) Key Differences Between Spread and Rest Operators (...)
- Purpose:
- Spread (...) expands elements from an array, object, or iterable.
- Rest (...) collects multiple elements into an array or object.
- Use Cases:
- Spread: Used for copying, merging, and passing function arguments.
- Rest: Used for function parameters and destructuring assignments.
- Where It's Used:
- Spread: Works with arrays, objects, and function calls.
- Rest: Works with function parameters and destructuring (arrays/objects).
- Behavior in Arrays:
- Spread: Expands an array into individual elements.
- Rest: Gathers multiple array elements into a single array.
- Behavior in Objects:
- Spread: Copies and merges objects.
- Rest: Collects remaining properties into a new object.
- Examples:
- Spread:
const arr = [1, 2, 3]; const newArr = [...arr, 4, 5]; // [1, 2, 3, 4, 5]
- Rest:
const [first, ...rest] = [10, 20, 30, 40]; console.log(rest); // [20, 30, 40]
✔ Spread (...) expands values into individual elements (arrays, objects, or function arguments).
✔ Rest (...) gathers multiple values into an array or object.
✔ Both use ... but serve opposite purposes.
✔ They make JavaScript code cleaner, shorter, and more readable!
Spread Operator (...) - Expands elements of an array or object
const arr1 = [1, 2, 3]; const arr2 = [...arr1, 4, 5]; console.log(arr2); // Output: [1, 2, 3, 4, 5]
Rest Operator (...) - Gathers multiple arguments into an array
function sum(...numbers) { return numbers.reduce((acc, num) => acc + num, 0); } console.log(sum(1, 2, 3, 4)); // Output: 10
Key Takeaways:
- ...spread expands arrays/objects.
- ...rest gathers function arguments into an array.
6. Default Parameters - Providing Default Values
function greet(name = "Sudip") { console.log(`Hello, ${name}!`); } greet(); // Output: Hello, Sudip!
Key Takeaways:
- Helps prevent undefined errors when parameters are missing.
- Improves function flexibility.
7. Promises - Handling Asynchronous Operations
A Promise in JavaScript is an object that represents the eventual completion or failure of an asynchronous operation. It helps to handle asynchronous tasks like fetching data from an API, reading files, or performing database queries.
A). What is a Promise?
A Promise is an object that may return a value in the future. It has three possible states:
- Pending → The initial state (operation is in progress).
- Fulfilled → The operation was successful.
- Rejected → The operation failed.
let promise = new Promise((resolve, reject) => { let success = true; // Simulate success or failure setTimeout(() => { if (success) { resolve("Promise resolved successfully!"); } else { reject("Promise rejected due to an error."); } }, 2000); }); console.log(promise); // Outputs: Promise { <pending> }
B). Using .then(), .catch(), and .finally()
Handling Promises
Once a promise is created, we can handle the result using:
- .then(callback) → Executes if the promise is fulfilled.
- .catch(callback) → Executes if the promise is rejected.
- .finally(callback) → Executes regardless of fulfillment or rejection.
promise .then(result => { console.log("Success:", result); }) .catch(error => { console.log("Error:", error); }) .finally(() => { console.log("Promise execution completed."); });
C). Chaining Promises
Promises can be chained to perform multiple asynchronous operations sequentially.
function asyncTask1() { return new Promise(resolve => setTimeout(() => resolve("Task 1 completed"), 1000)); } function asyncTask2() { return new Promise(resolve => setTimeout(() => resolve("Task 2 completed"), 2000)); } asyncTask1() .then(result => { console.log(result); return asyncTask2(); // Return another promise }) .then(result => { console.log(result); }) .catch(error => console.log("Error:", error));
D). Using Promise.all() – Running Promises in Parallel
Promise.all() runs multiple promises at the same time and waits for all of them to resolve.
let promise1 = new Promise(resolve => setTimeout(() => resolve("Promise 1 resolved"), 2000)); let promise2 = new Promise(resolve => setTimeout(() => resolve("Promise 2 resolved"), 1000)); Promise.all([promise1, promise2]) .then(results => console.log("All results:", results)) .catch(error => console.log("Error:", error));
Note: If any promise fails, Promise.all() rejects immediately.
E). Using Promise.race() – First Resolved Promise Wins
Promise.race() returns the first resolved or rejected promise.
Promise.race([promise1, promise2]) .then(result => console.log("Fastest resolved:", result)) .catch(error => console.log("Error:", error));
F). Using async/await for Cleaner Code
async/await is a modern way to handle promises without chaining .then().
async function fetchData() { try { let data = await new Promise(resolve => setTimeout(() => resolve("Data fetched"), 2000)); console.log(data); } catch (error) { console.log("Error:", error); } } fetchData();
Key Differences Between Callbacks and Promises
- Code Readability
- Callbacks → Can lead to "callback hell" (nested functions, hard to read).
- Promises → Provide chaining (.then()), making code cleaner and more readable.
- Error Handling
- Callbacks → Error handling is manual (checking err in each function).
- Promises → Centralized error handling using .catch().
- Execution Flow
- Callbacks → Harder to manage multiple async operations.
- Promises → Easier to chain multiple async tasks in a sequence.
- Parallel Execution
- Callbacks → Running multiple async functions in parallel is complex.
- Promises → Use Promise.all() to run multiple promises in parallel.
- Return Values
- Callbacks → Cannot return values directly (need to pass results via another callback).
- Promises → Can return another promise, allowing chaining.
- Error Propagation
- Callbacks → If an error occurs, it may not be caught properly.
- Promises → Errors automatically propagate to .catch().
Example of Callback Hell (Difficult to Read)
function getData(callback) { setTimeout(() => { console.log("Data received"); callback(); }, 1000); } getData(() => { getData(() => { getData(() => { console.log("Nested callbacks (callback hell)"); }); }); });
Example of Promises (Cleaner Code)
function getData() { return new Promise(resolve => { setTimeout(() => { console.log("Data received"); resolve(); }, 1000); }); } getData() .then(getData) .then(getData) .then(() => console.log("Promises make it readable!"));
✔ Promises simplify asynchronous operations and provide better error handling.
✔ Use .then(), .catch(), and .finally() to handle promise resolution and rejection.
✔ Use async/await for cleaner, synchronous-looking asynchronous code.
8. Modules (import/export) - Organizing Code
JavaScript modules allow developers to break code into smaller, reusable files that can be imported and exported across different parts of an application. This improves maintainability, scalability, and reusability of code.
8.1. Why Use Modules?
✔ Encapsulation: Keep different functionalities in separate files.
✔ Reusability: Share code across multiple files without duplication.
✔ Maintainability: Easier to update and debug specific parts of an application.
✔ Avoid Global Scope Pollution: Prevents conflicts by keeping variables/functions inside modules.
8.2. Exporting Modules
To use a module in another file, we must first export it. There are two types of exports in JavaScript:
A) Named Export (export)
Allows exporting multiple values from a file.
// math.js - Named Exports export const add = (a, b) => a + b; export const subtract = (a, b) => a - b;
📌 Multiple named exports can exist in a single file.
B) Default Export (export default)
Allows exporting only one value per file.
// utils.js - Default Export export default function greet(name) { return `Hello, ${name}!`; }
📌 Each file can have only one default export.
8.3. Importing Modules
Once we have exported functions or variables, we can import them into another file using import.
A) Importing Named Exports
// main.js import { add, subtract } from "./math.js"; console.log(add(5, 3)); // Output: 8 console.log(subtract(10, 4)); // Output: 6
📌 Named imports must match the exact export names.
📌 You can also rename named imports:
import { add as sum } from "./math.js"; console.log(sum(4, 6)); // Output: 10
B) Importing Default Export
// main.js import greet from "./utils.js"; console.log(greet("Alice")); // Output: Hello, Alice!
📌 Default imports can be named anything.
C) Importing Everything (*)
You can import everything from a module as an object.
// main.js import * as MathUtils from "./math.js"; console.log(MathUtils.add(5, 2)); // Output: 7 console.log(MathUtils.subtract(9, 3)); // Output: 6
📌 Useful when you need multiple exports from a single module.
8.4. Dynamic Imports (import()) – Lazy Loading
JavaScript also allows dynamic imports, which means importing a module only when needed. This is useful for performance optimization.
async function loadModule() { const { add } = await import("./math.js"); console.log(add(10, 5)); // Output: 15 } loadModule();
📌 Useful for loading large modules only when required.
8.5. Module Support in Browsers and Node.js
- Browsers: Use <script type="module"> to load modules in HTML.
<script type="module" src="main.js"></script>
- Node.js: Use "type": "module" in package.json or the .mjs file extension.
8.6. Key Takeaways
✅ Modules help structure code better by keeping related functionality together.
✅ Named exports allow multiple exports from a module, while default exports provide a single export.
✅ Imports must match export names exactly, but default exports can have any name.
✅ Dynamic imports (import()) allow lazy-loading of modules for better performance.
✅ Modules prevent global scope pollution and make JavaScript code more modular and maintainable.
Conclusion
ES6 brought numerous improvements to JavaScript, making it more readable, efficient, and powerful. By mastering these features, developers can write cleaner, modern, and scalable JavaScript code. Start implementing these ES6 features in your projects today to enhance your JavaScript skills!
Do you have any favorite ES6 features? Let us know in the comments below!
0 Comments