Module 2

Data Structures & Async

Spread/Rest β€’ Destructuring β€’ JSON β€’ Arrays β€’ Promises

🧩 Modern JavaScript Data & Async

Modern JavaScript provides powerful tools for working with data. This module covers ES6+ syntax features, data structures, and the async patterns that make JavaScript unique.

In Module 1, you learned the fundamentals: variables, functions, objects, and control flow. Now we level up. This module introduces the modern ES6+ tools that professional developers use daily β€” cleaner syntax for working with data (spread, destructuring, template literals), the universal data format of the web (JSON), powerful array transformations, robust error handling, and the async patterns (Promises & async/await) that make JavaScript uniquely suited for web applications.

🎯 By the end of this module, you will:

  • Use spread/rest operators and destructuring for clean data access
  • Work with JSON β€” the language of web APIs
  • Master array methods (map, filter, reduce, …)
  • Handle errors with try/catch
  • Understand Promises and write async/await code

πŸ”„ Spread & Rest Operators

The ... operator does two opposite things depending on where it's used:

πŸ“₯
REST (Gather)
1  2  3 β†’ [1, 2, 3]
function sum(...nums) { }
Multiple items β†’ One array
πŸ“€
SPREAD (Scatter)
[1, 2, 3] β†’ 1  2  3
Math.max(...arr)
One array β†’ Multiple items

πŸ“ Quick Reference

fn(a, ...rest)REST β€” Parameters
[a, ...rest] = arrREST β€” Destructure
[...arr1, ...arr2]SPREAD β€” Arrays
{...obj, key: val}SPREAD β€” Objects

REST: Collecting Multiple β†’ One

The rest operator (...) collects remaining items into a single array. Use it in function parameters to accept any number of arguments, or in destructuring to capture "the rest" of the values you didn't explicitly name.

// REST in function parameters: collect arguments into array
function sum(...numbers) {
    return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4)); // 10

// REST with named parameters (rest must be last!)
function greet(greeting, ...names) {
    return `${greeting} ${names.join(', ')}!`;
}
console.log(greet('Hello', 'Ali', 'Sara', 'Omar'));
// "Hello Ali, Sara, Omar!"

// REST in array destructuring
const [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(rest);   // [3, 4, 5]

// REST in object destructuring
const user = { name: 'Mehdi', age: 32, city: 'Casa', role: 'dev' };
const { name, ...otherProps } = user;
console.log(otherProps); // { age: 32, city: 'Casa', role: 'dev' }

SPREAD: Expanding One β†’ Multiple

The spread operator (...) does the opposite of rest β€” it expands an array or object into individual elements. Use it to clone arrays, merge objects, or pass array items as separate function arguments.

// SPREAD in arrays
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2];  // [1, 2, 3, 4, 5, 6]
const clone = [...arr1];              // shallow copy

// SPREAD in function calls
const nums = [1, 5, 3, 9, 2];
console.log(Math.max(...nums)); // 9

// SPREAD in objects
const userObj = { name: "Mehdi", age: 32 };
const location = { city: "Casablanca", country: "Morocco" };
const profile = { ...userObj, ...location };
// { name: "Mehdi", age: 32, city: "Casablanca", country: "Morocco" }

// Override properties (later values win)
const updated = { ...userObj, age: 33, role: "admin" };
// { name: "Mehdi", age: 33, role: "admin" }

Putting It Together: REST + SPREAD

REST and SPREAD often work together. A function can collect arguments with rest, then expand them with spread inside the function body. This pattern is especially useful for immutable data updates β€” a core pattern in modern frameworks like React.

// Use both in same function!
function mergeAndLog(label, ...arrays) {    // REST: collect arrays
    const merged = [].concat(...arrays);     // SPREAD: expand each
    console.log(`${label}:`, merged);
    return merged;
}
mergeAndLog('Numbers', [1, 2], [3, 4], [5, 6]);

// Practical: updating nested objects immutably
const state = {
    user: { name: 'Ali', age: 25 },
    settings: { theme: 'dark', lang: 'en' }
};
const newState = {
    ...state,                  // SPREAD: copy all properties
    user: { ...state.user, age: 26 }  // SPREAD + override
};
console.log(state.user.age);    // 25 (original unchanged)
console.log(newState.user.age); // 26

Common Mistake: Rest must be the last parameter. function bad(...first, last) { } is a SyntaxError! Also, you can't spread into nothing: const x = ...arr; β€” spread must be inside [], {}, or a function call.

πŸ“ Template Literals

Template literals use backticks (`) and provide a cleaner, more readable way to work with strings compared to concatenation.

Before ES6, building strings with variables meant awkward concatenation: mixing + operators, wrapping values in quotes, and escaping newlines with \n. Template literals replaced all of that with a single, intuitive syntax β€” backticks and ${} placeholders. They're now the default choice for any string that includes variables, expressions, or multiple lines.

❌ Concatenation (Old)
'Hello ' + name + '!'
βœ… Template Literal (Modern)
`Hello ${name}!`
πŸ“ Multi-line Strings ⚑ Expression Interpolation 🏷️ Tagged Templates
const user = { name: 'Mehdi', email: 'mehdi@example.com' };
const orderCount = 5;
const total = 249.99;

// ❌ CONCATENATION: Hard to read
const msg1 = 'Hello ' + user.name + ',\n' +
           'You have ' + orderCount + ' orders.\n' +
           'Total: $' + total.toFixed(2);

// βœ… TEMPLATE LITERAL: Clean, readable
const msg2 = `Hello ${user.name},
You have ${orderCount} orders.
Total: $${total.toFixed(2)}
Email: ${user.email}`;

// Expressions inside ${}
const price = 100;
const tax = 0.2;
console.log(`Total: ${price * (1 + tax)} MAD`); // Total: 120 MAD

// Function calls inside ${}
const loud = (text) => text.toUpperCase();
console.log(`Message: ${loud('hello')}`); // Message: HELLO

// Use Case: HTML Generation
function createUserCard(u) {
    return `
      <div class="user-card">
        <h3>${u.name}</h3>
        <p>Email: ${u.email}</p>
        <span class="status ${u.active ? 'active' : 'inactive'}">
          ${u.active ? '● Active' : 'β—‹ Inactive'}
        </span>
      </div>`;
}

When to use each: Template literals for multi-line strings, complex interpolation, HTML/SQL generation. Concatenation for simple single additions like 'Hello' + name. General rule: if you need more than 2 values or multiple lines β†’ use templates.

⚠️ Common Mistakes with Template Literals

  • Wrong quotes: Using regular quotes ('...' or "...") instead of backticks (`...`). Interpolation ${} only works inside backticks!
  • Forgetting ${}: Writing `Hello name` instead of `Hello ${name}` β€” the variable name becomes a literal string.
  • Nested backticks: If you need backticks inside a template literal, escape them with \` or use a different quoting strategy.

πŸ“¦ Destructuring

Destructuring lets you extract values from arrays or properties from objects into separate variables β€” with a single statement.

πŸ“¦
Object Destructuring
const { name, age } = obj;
πŸ“š
Array Destructuring
const [ first, second ] = arr;
{ name: alias } { x = default } { ...rest } [ , second ]
Object destructuring
const user = { name: "Mehdi", age: 32, city: "Casablanca" };

// Extract properties into variables
const { name, age } = user;
console.log(name); // "Mehdi"

// Rename while destructuring
const { name: userName, city: location } = user;
console.log(userName); // "Mehdi"
console.log(location); // "Casablanca"

// Default values (if property doesn't exist)
const { country = "Morocco", email = "none" } = user;
console.log(country); // "Morocco"
Function parameters destructuring
// Destructure in the parameter list
function displayUser({ name, age, city = "Unknown" }) {
    console.log(`Name: ${name}, Age: ${age}, City: ${city}`);
}
displayUser({ name: "Youssef", age: 28 });
// Name: Youssef, Age: 28, City: Unknown

// REST in destructuring
const { name, ...rest } = { name: "Ali", age: 25, city: "Fes" };
console.log(rest); // { age: 25, city: "Fes" }
Array destructuring
const colors = ["red", "green", "blue", "yellow"];

// Extract by position
const [first, second] = colors;
console.log(first); // "red"

// Skip elements using commas
const [, , third] = colors;
console.log(third); // "blue"

// Swapping variables without temp variable
let x = 1, y = 2;
[x, y] = [y, x];
console.log(x, y); // 2 1
REST in array destructuring
const numbers = [1, 2, 3, 4, 5];
const [first, ...rest] = numbers;
console.log(rest); // [2, 3, 4, 5]

// Function returning multiple values
function getCoordinates() { return [10, 20]; }
const [x, y] = getCoordinates();

// Destructure in loop
const pairs = [[1, 2], [3, 4], [5, 6]];
pairs.forEach(([a, b]) => {
    console.log(`a=${a}, b=${b}`);
});
Mixed: destructuring arrays of objects
const users = [
    { id: 1, name: "Fatima", role: "admin" },
    { id: 2, name: "Hassan", role: "user" },
    { id: 3, name: "Nadia", role: "moderator" }
];

// Destructure first user's properties
const [{ name: firstName, role }] = users;
console.log(firstName); // "Fatima"

// Map with destructuring
const names = users.map(({ name }) => name);
console.log(names); // ["Fatima", "Hassan", "Nadia"]

// Filter admins using destructuring
const admins = users.filter(({ role }) => role === "admin");

When to use destructuring: Function parameters with many options, extracting data from API responses, working with React props/state, swapping variables, cleaning up code that repeatedly accesses obj.property.

πŸ“‹ JSON

The Problem: Storing Data in Text Format

Before standardized formats, storing structured data was a programmer's nightmare. Every developer had their own encoding rules.

⚠️ The Pre-Standard Era

πŸ‘¨β€πŸ’» Programmer A: Comma-separated
Alice,25,alice@example.com
Bob,30,bob@example.com

❌ What if name contains a comma? Adding new fields breaks parsing. No optional fields.

πŸ‘¨β€πŸ’» Programmer B: Fixed-width columns
Alice     25alice@example.com
Bob       30bob@example.com

❌ Names longer than 10 chars break format. Wastes space with padding.

πŸ‘¨β€πŸ’» Programmer C: Custom delimiters
name:Alice|age:25|email:alice@example.com

❌ What if data contains | or :? Complex parsing. Custom parser per app.

The Solution: Standardized Formats

πŸ“„ XML (1998)
<user>
  <name>Alice</name>
  <age>25</age>
  <email>alice@mail.com</email>
</user>
πŸ“ ~116 characters

Verbose, large files, complex parsing. Good for documents.

Self-describing Verbose Heavy parsing
✨ Winner
πŸš€ JSON (2001)
{
  "name": "Alice",
  "age": 25,
  "email": "alice@mail.com"
}
πŸ“ ~68 characters (41% smaller!)

Lightweight, 30-50% smaller, native JS support, perfect for APIs.

Lightweight Native JS API Standard

JSON Syntax & Data Types

πŸ“ String "hello"
πŸ”’ Number 42, 3.14
βœ… Boolean true, false
β­• Null null
πŸ“¦ Array [1, 2, 3]
πŸ—‚οΈ Object {"k":"v"}
🚫 Functions
🚫 undefined
🚫 NaN / Infinity
FeatureJSONJS Object
KeysMust be double-quotedUnquoted or any string
StringsDouble quotes onlySingle or double quotes
Trailing commasβŒβœ…
CommentsβŒβœ…

Working with JSON in JavaScript

JSON.parse() β€” JSON string β†’ JavaScript object
const jsonString = '{"name":"Alice","age":25,"skills":["JavaScript","Python"]}';
const user = JSON.parse(jsonString);

console.log(user.name);      // "Alice"
console.log(user.skills[0]); // "JavaScript"
console.log(typeof user);    // "object"
JSON.stringify() β€” JavaScript object β†’ JSON string
const user = { name: "Bob", age: 30, skills: ["React", "Node.js"] };

// Compact
const json = JSON.stringify(user);
// '{"name":"Bob","age":30,"skills":["React","Node.js"]}'

// Pretty-print with indentation
console.log(JSON.stringify(user, null, 2));
// {
//   "name": "Bob",
//   "age": 30,
//   ...
// }
Filtering with replacer
const user = { name: "Alice", age: 25, password: "secret123" };

// Array of allowed keys
const safe1 = JSON.stringify(user, ["name", "email"]);
// '{"name":"Alice"}'

// Replacer function for more control
const safe2 = JSON.stringify(user, (key, value) => {
    if (key === "password") return undefined;  // skip!
    return value;
});
Handling JSON parse errors
// Safe parsing with default fallback
function safeParse(jsonString, defaultValue = null) {
    try {
        return JSON.parse(jsonString);
    } catch(e) {
        return defaultValue;
    }
}

const data = safeParse('invalid json', { name: "Default" });
console.log(data); // { name: "Default" }

🌐 Real-World JSON Use Cases

πŸ“‘REST APIs β€” sending/receiving data from web servers
βš™οΈConfig files β€” package.json, tsconfig.json, VS Code settings
πŸ’ΎLocalStorage β€” store JS objects in browser storage
πŸ—„οΈNoSQL databases β€” MongoDB stores BSON (Binary JSON)

πŸ“š Arrays & Array Methods

Array methods are the Swiss Army knife of JavaScript. Master these and you'll write cleaner, more expressive code.

πŸ“‹ Array Methods Decision Guide

πŸ”„
.map()
Transform each item
πŸ”
.filter()
Select some items
βž•
.reduce()
Combine to one value
🎯
.find()
Find one item
πŸ”
.forEach()
Side effects only
πŸ”’
.sort()
Order items ⚠️

πŸ”§ Complete Array Methods Reference

πŸ’‘ Hover over any method to see details and examples

πŸ”„ Transform (Return new array)
.map() .filter() .reduce() .flatMap() .flat() .slice() .concat()
⚠️ Mutating (Change original)
.push() .pop() .shift() .unshift() .splice() .sort() .reverse()
✨ Safe copies (ES2023+)
.toSorted() .toReversed() .toSpliced() .with()
🏭 Static & Conversion
Array.isArray() Array.from() .join() .entries()
⚠️
.sort() mutates the original! Use [...arr].sort() or .toSorted() to preserve original.

Below is a practical example using an array of order objects. Each method serves a specific purpose β€” map transforms, filter selects, reduce combines, find searches, and some/every check conditions:

const orders = [
    { id: 1, total: 50, status: 'paid' },
    { id: 2, total: 120, status: 'pending' },
    { id: 3, total: 80, status: 'paid' }
];

// Transform β†’ .map()
const ids = orders.map(o => o.id);           // [1, 2, 3]

// Select β†’ .filter()
const paid = orders.filter(o => o.status === 'paid');  // 2 items

// Combine β†’ .reduce()
const revenue = paid.reduce((sum, o) => sum + o.total, 0);  // 130

// Find β†’ .find()
const order = orders.find(o => o.id === 2);  // { id: 2, total: 120, ... }

// Check any β†’ .some()
orders.some(o => o.total > 100);  // true

// Check all β†’ .every()
orders.every(o => o.status === 'paid');  // false

The reduce method deserves special attention β€” it's the most versatile array method and can implement almost any array transformation. Below you'll also see sort (which mutates the original!) and how to chain multiple methods together for powerful one-line transformations:

// reduce β€” the most powerful method
const sum = [1, 2, 3, 4].reduce((acc, n) => acc + n, 0);  // 10

// reduce for grouping
const people = [
    { name: "Mehdi", dept: "IT" },
    { name: "Ada", dept: "CS" },
    { name: "Alan", dept: "CS" }
];
const grouped = people.reduce((groups, person) => {
    const key = person.dept;
    groups[key] = groups[key] || [];
    groups[key].push(person);
    return groups;
}, {});
// { IT: [{...}], CS: [{...}, {...}] }

// sort β€” ⚠️ mutates the original array!
const nums = [10, 2, 5];
console.log([...nums].sort((a, b) => a - b)); // [2, 5, 10]

// Chaining methods
const result = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    .filter(n => n % 2 === 0)        // [2, 4, 6, 8, 10]
    .map(n => n ** 2)                 // [4, 16, 36, 64, 100]
    .reduce((sum, n) => sum + n, 0);  // 220

NB: Methods like forEach, map, filter, sort accept callbacks. They abstract away "how to iterate" so you focus on what to do. This is the practical power of callbacks.

πŸ›‘οΈ Error Handling

Use try { ... } catch (err) { ... } finally { ... } to handle exceptions. You can also throw your own errors when inputs are invalid.

πŸ›‘οΈ Error Handling Flow

try { }
Code that might fail
βœ“
Success
βœ—
Error
Continue
catch { }
↓
finally { }
Always runs (optional)
TypeError RangeError SyntaxError ReferenceError
// Basic try/catch
const text = '{"id":1,"name":"Mehdi"}';
try {
    const obj = JSON.parse(text);
    console.log('Parsed:', obj.name);
} catch (err) {
    console.error('Invalid JSON:', err.message);
}

// Throwing your own errors
function validateAge(age) {
    if (typeof age !== 'number' || Number.isNaN(age)) {
        throw new TypeError('age must be a number');
    }
    if (age < 0) throw new RangeError('age must be >= 0');
    return age;
}

try {
    validateAge('x');
} catch (e) {
    console.error('Validation error:', e.message);
}

The finally block runs no matter what β€” whether the code succeeded or threw an error. It's the perfect place for cleanup tasks like closing connections, hiding loading spinners, or releasing resources:

// finally for cleanup
function work() {
    console.log('start');
    try {
        JSON.parse('{ bad json');
    } catch (e) {
        console.error('caught:', e.message);
    } finally {
        console.log('cleanup always runs');
    }
    console.log('end');
}
work();

⚠️ Heads-up: try/catch only catches errors that happen inside the try block. Errors in setTimeout callbacks run later, outside the try:

// ❌ BAD: try/catch won't catch errors inside setTimeout
try {
    setTimeout(() => {
        throw new Error('Async boom!');  // Runs later, OUTSIDE the try
    }, 100);
} catch (e) {
    console.error('Never reached!');  // Won't run
}

// βœ… GOOD: Catch inside the callback itself
setTimeout(() => {
    try {
        throw new Error('Async boom!');
    } catch (e) {
        console.error('Caught inside callback:', e.message);
    }
}, 100);

// βœ… BEST: Use Promises/async-await instead
async function safeAsync() {
    try {
        const result = await fetchData();
        console.log(result);
    } catch (e) {
        console.error('Caught with async/await:', e.message);
    }
}

Error Levels: Bubbling to the Nearest catch

When an error occurs, JavaScript looks for the nearest surrounding try/catch up the call stack. If none is found, it reaches the global error handler.

Caught at top level
function c() { throw new Error('boom in c'); }
function b() { c(); }    // no catch here
function a() {
    try {
        b();
        console.log('after b');  // not reached
    } catch (e) {
        console.log('caught in a:', e.message);
    }
}
a();
Caught closer to the source
function c() { throw new Error('boom in c'); }
function b() {
    try { c(); }
    catch (e) { console.log('handled in b:', e.message); }
}
function a() {
    b();
    console.log('a continues');  // reached!
}
a();

⚑ Where to Place try/catch

A common beginner mistake is wrapping every single line in its own try/catch. Instead, place your error handling at function boundaries β€” wrap the entire logical operation, not individual steps. This keeps your code clean and lets errors bubble to the right level:

// ❌ BAD: Catching too granularly
function processUserBad(data) {
    let parsed;
    try { parsed = JSON.parse(data); }
    catch (e) { console.error('Parse error'); return null; }
    try { validate(parsed); }
    catch (e) { console.error('Validation error'); return null; }
    return parsed;
}

// βœ… GOOD: Catch at function boundary
function processUserGood(data) {
    try {
        const parsed = JSON.parse(data);
        validate(parsed);
        save(parsed);
        return { success: true, data: parsed };
    } catch (error) {
        console.error('Failed to process user:', error.message);
        return { success: false, error: error.message };
    }
}

Three levels of error handling: 1) Function boundary β€” catch at the top level of public functions. 2) Module boundary β€” catch when crossing module boundaries (API routes, event handlers). 3) Application boundary β€” global error handler as last resort.

Here's what each level looks like in practice β€” from the most local (function) to the most global (application-wide handler):

// Level 1: Function boundary (pure function)
function calculateTotal(items) {
    try {
        return items.reduce((sum, item) => sum + item.price, 0);
    } catch (error) {
        console.error('Calculation error:', error.message);
        return 0;  // Safe default
    }
}

// Level 2: Module boundary (API route handler)
function handleUserRequest(req, res) {
    try {
        const user = parseUser(req.body);
        const saved = saveToDatabase(user);
        res.json({ success: true, user: saved });
    } catch (error) {
        console.error('[API Error]', error.message);
        res.status(400).json({
            success: false,
            error: 'Failed to create user'
        });
    }
}

// Level 3: Global error handler (last resort)
window.addEventListener('error', (event) => {
    console.error('[Global Error]', event.error);
    // Send to error tracking service
});

// Don't catch too early β€” let errors bubble to the right level!

⏳ Promises & Async/Await

πŸ• The Real-World Problem: Waiting in JavaScript

Imagine ordering pizza online. You don't stare at the screen waiting β€” you watch TV, do homework. When the pizza arrives, the doorbell rings and you handle it.

JavaScript works the same way! Some operations take time (fetching data, reading files). If JS stopped and waited, your webpage would freeze.

❌ Blocking (Bad)
1. Order pizza
2. Wait... doing nothing...
3. Page frozen...
4. Pizza arrives
5. Finally continue!
βœ… Non-Blocking (Good)
1. Order pizza
2. Watch TV (page responsive!)
3. Do homework (scrolling works!)
4. πŸ”” Doorbell! Pizza arrives
5. Handle delivery, continue

⏱️ Synchronous vs Asynchronous

🚫 Synchronous β€” Tasks run one at a time
Task 1
β†’
⏳
β†’
Task 2
β†’
⏳
β†’
Task 3
100ms + 100ms + 100ms = 300ms SLOW! ❌
⚑ Asynchronous β€” Tasks start immediately, run in parallel
Task 1
↗️
running…
Task 2
↗️
running…
Task 3
↗️
running…
max(100ms, 100ms, 100ms) = 100ms FAST! βœ…
πŸ’‘ Key Insight: JavaScript is single-threaded, but it delegates slow work (network, timers, I/O) to the browser/OS. While waiting, the JS thread stays free to handle other code. This is why async programming is essential for modern web applications.

🎯 From Callbacks to Async/Await

  1. Callbacks (original) β€” functions passed as arguments, but leads to "pyramid of doom"
  2. Promises (ES2015) β€” objects representing future values, chainable with .then()
  3. Async/Await (ES2017) β€” syntactic sugar over promises, looks like synchronous code

We'll explore each one, see their problems, and understand why we moved to the next solution.

1️⃣ Callbacks: The Original Async Pattern

A callback is a function you pass as an argument to another function, to be called later when something finishes.

πŸ’‘ Real-world analogy: You give your phone number to a restaurant. When your table is ready, they call you back. You don't stand there waiting β€” you do other things until they notify you!

Simple callback example: setTimeout

setTimeout is the simplest built-in async function in JavaScript. It takes a callback function and a delay in milliseconds, then runs the callback after the delay. Pay close attention to the output order β€” it reveals how JavaScript handles async code:

// The simplest async function: setTimeout
console.log('1. Ordering pizza...');

setTimeout(function() {
    console.log('3. πŸ• Pizza delivered!');
}, 2000); // Wait 2 seconds (2000ms)

console.log('2. Doing other stuff while waiting...');

// Output order:
// 1. Ordering pizza...
// 2. Doing other stuff while waiting...
// (2 second pause)
// 3. πŸ• Pizza delivered!

πŸ’‘ Key Insight: Notice the output order! Line 2 runs before line 3, even though line 3 is written first. That's because setTimeout schedules the callback for later β€” JavaScript doesn't wait, it continues executing the next line immediately.

The Problem: Callback Hell (Pyramid of Doom)

When you need multiple async operations that depend on each other, callbacks get nested deeper and deeper.

Dependency chain: In the flow below β€” fetch users β†’ user details β†’ user posts β†’ comments β€” each step depends on the previous step's result, so you can't start the next step until the prior callback finishes. This forces nesting with plain callbacks.

// Pyramid of doom: callback inside callback inside callback…
// Each API expects a callback: (err, data)
fetchUsers(function (err, users) {
    if (err) return console.error(err);
    fetchUserDetails(users[0].id, function (err, details) {
        if (err) return console.error(err);
        fetchUserPosts(users[0].id, function (err, posts) {
            if (err) return console.error(err);
            fetchPostComments(posts[0].id, function (err, comments) {
                if (err) return console.error(err);
                console.log('done:', { details, comments });
            });
        });
    });
});

Problems with callbacks: Deep nesting hard to read, error handling repeated everywhere (if (err)), hard to reason about flow, difficult to debug.

πŸ”„ From Callbacks to Promises

Now let's see how the exact same task (users β†’ details β†’ posts β†’ comments) becomes cleaner with promises. No more nesting!

// The same 4-step chain, but with promises β€” flat and readable!
fetchUsers()
    .then(users => {
        console.log('Step 1: Got users');
        return fetchUserDetails(users[0].id);
    })
    .then(details => {
        console.log('Step 2: Got details');
        return fetchUserPosts(details.userId);
    })
    .then(posts => {
        console.log('Step 3: Got posts');
        return fetchPostComments(posts[0].id);
    })
    .then(comments => {
        console.log('Step 4: Got comments');
        console.log('Done!', comments);
    })
    .catch(err => {
        // ONE catch handles errors from ANY step above!
        console.error('Error:', err.message);
    });

βœ… Compare: 4 levels of nesting became a flat chain. Error handling went from 4 repeated if (err) checks to a single .catch(). Same logic, much more readable!

2️⃣ Promises: A Better Way

A Promise is an object representing a value that will be available in the future β€” a "receipt" for async work. It's either pending, fulfilled (success), or rejected (error).

✨ Why Use Promises? Key Advantages

πŸ”—
Escape Callback Hell

Flatten nested callbacks into a readable chain: .then().then().then() β€” linear and easy to follow.

🎯
Flexible Consumption

The same Promise can be consumed with .then() for async callback-style or with modern syntax (covered next). The API doesn't force a style on you!

⚑
Never Blocks the Thread

Promise-based code is always non-blocking. The event loop continues running β€” UI stays responsive, other code executes while waiting for results.

πŸ›‘οΈ
Centralized Error Handling

One .catch() at the end handles errors from any step. No more repetitive if (err) in every callback.

πŸ• Real-world analogy: You order a pizza and get a receipt (the promise). The receipt isn't the pizza itself β€” it's a promise that you'll get pizza later. Two possible outcomes:
  • βœ… Fulfilled: Pizza arrives! (success)
  • ❌ Rejected: Restaurant is closed. (error)

A Promise is always in one of three states. It starts as pending, and eventually transitions to either fulfilled (success) or rejected (failure). Once it transitions, it's called settled β€” and it can never change state again. This is a key guarantee: a promise settles exactly once.

⏳
Pending

The initial state of every promise. The asynchronous operation is still running β€” the result is not yet available. During this phase, .then() and .catch() handlers are registered but not called. The promise is waiting for either resolve() or reject() to be called inside the executor.

↙️ resolve(value)
βœ…
Fulfilled

The operation completed successfully. The resolve(value) function was called, and the promise now carries a result value. All .then(onFulfilled) handlers run with that value. If the promise is already fulfilled when .then() is attached, the handler still runs (asynchronously, in the microtask queue).

↙️ reject(error)
❌
Rejected

The operation failed. The reject(error) function was called (or an exception was thrown inside the executor). All .catch(onRejected) handlers run with the error. If no .catch() is attached, you'll see an UnhandledPromiseRejection warning β€” always handle rejections!

πŸ”’ "Settled" = Final State

Both fulfilled and rejected are called settled states. Once a promise settles, its value or error is locked in forever β€” calling resolve() or reject() again has no effect. This immutability is what makes promises safe to pass around: multiple consumers can attach .then() handlers without worrying about the state changing.

πŸ“ Promise Syntax β€” Step by Step

Step 1: Creating a Promise

To create a Promise, you use new Promise() and pass it an executor function with two parameters: resolve (call when successful) and reject (call when something fails). The promise starts in a pending state until one of them is called:

// Creating a promise that will succeed after 1 second
const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Success! πŸŽ‰');  // Call resolve() when successful
    }, 1000);
});

console.log('Promise created:', myPromise);  // Promise { <pending> }

Key concepts:

  • new Promise() β€” Creates a new promise object
  • resolve β€” Function to call when the task succeeds
  • reject β€” Function to call when the task fails
  • The promise starts in pending state

Step 2: Using the Promise with .then()

Once you have a promise, you consume it with .then(). The callback inside .then() receives the resolved value and runs only when the promise fulfills:

const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => resolve('Data loaded!'), 1000);
});

// .then() runs when the promise resolves
myPromise.then((result) => {
    console.log('Got result:', result);  // Runs after 1 second
});

console.log('Promise started, waiting...');

Step 3: Handling Errors with .catch()

When a promise calls reject() instead of resolve(), the .then() callback is skipped and the error flows to .catch(). This is how you handle failures gracefully:

const failingPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject(new Error('Something went wrong! ❌'));
    }, 1000);
});

failingPromise
    .then((result) => {
        console.log('Success:', result);  // Won't run
    })
    .catch((error) => {
        console.error('Caught error:', error.message);  // Runs!
    });

Putting It All Together

Now let’s combine all three concepts β€” creating a promise, consuming it with .then(), catching errors with .catch(), and cleaning up with .finally() β€” into a single, complete example:

const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('Data loaded!');      // β†’ fulfilled
        } else {
            reject(new Error('Failed'));  // β†’ rejected
        }
    }, 1000);
});

// Consuming with .then() / .catch() / .finally()
myPromise
    .then(data => console.log(data))      // "Data loaded!"
    .catch(err => console.error(err))
    .finally(() => console.log("Done"));  // always runs

Error Propagation

One of the best features: one .catch() at the end handles errors from ANY step!

getUser(id)
    .then(user => {
        console.log('Step 1: Got user');
        return getProfile(user);  // This might fail
    })
    .then(profile => {
        console.log('Step 2: Got profile');  // Skipped if step 1 fails
        return getSettings(profile);
    })
    .then(settings => {
        console.log('Step 3: Got settings');
    })
    .catch(err => {
        // This ONE catch handles errors from ANY step above!
        console.error('Error:', err.message);
    });

πŸ”‘ Key Point: Errors "bubble up" through the chain. If step 2 fails, steps 3, 4, 5 all get skipped and the error goes straight to .catch(). Much cleaner than callbacks!

πŸ“Š Interactive Promise Lifecycle Diagram

Use the controls below to see how promises move through different states:

🎬 Choose a scenario
Step 0 / 0

Pending ⏳ Waiting not settled yet Fulfilled βœ“ Success has value Rejected βœ— Error has reason .then() handles success returns new Promise .catch() handles error returns new Promise .finally() cleanup handler always runs resolve(value) reject(reason) value β†’ error β†’ error ↓ new Promise ⟲ finally ↓ finally ↑

πŸ“ Code Examples

// Promise resolves β†’ .then() receives the value
const promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve('Success! Data loaded'), 1000);
});

promise.then(value => {
    console.log(value);             // "Success! Data loaded"
    return value.toUpperCase();
})
.then(transformed => {
    console.log(transformed);       // "SUCCESS! DATA LOADED"
});
// Promise rejects β†’ .catch() handles the error
const promise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('Network timeout')), 1000);
});

promise.then(value => {
    console.log('This will not run');
})
.catch(error => {
    console.error('Caught:', error.message);  // "Network timeout"
});
// .catch() recovers: transforms error β†’ success
const promise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('API failed')), 1000);
});

promise
    .then(value => value)            // Skipped!
    .catch(error => {
        console.error('Caught:', error.message);
        return 'Using cached data';  // Recovery!
    })
    .then(data => {
        console.log('Continues:', data);  // "Using cached data"
    });
// Error in .then() jumps to .catch()
const promise = new Promise((resolve) => {
    setTimeout(() => resolve({ id: 42, name: 'Alice' }), 500);
});

promise.then(user => {
    console.log('User received:', user);
    if (user.id < 100) {
        throw new Error('User ID too low');
    }
    return user;
})
.catch(error => {
    console.error('Caught:', error.message);  // "User ID too low"
});
// Chaining: .then() returns a promise β†’ waits for it
function fetchUser(id) {
    return new Promise(resolve => {
        setTimeout(() => resolve({ id, name: 'Bob' }), 300);
    });
}
function fetchUserPosts(userId) {
    return new Promise(resolve => {
        setTimeout(() => resolve(['Post 1', 'Post 2']), 400);
    });
}

fetchUser(123).then(user => {
    console.log('Got user:', user);
    return fetchUserPosts(user.id);  // Returns new promise!
})
.then(posts => {
    console.log('Got posts:', posts);
});
// .finally() always runs β€” perfect for cleanup
let isLoading = true;

fetchData()
    .then(data => console.log('Success:', data))
    .catch(error => console.error('Error:', error.message))
    .finally(() => {
        isLoading = false;  // Cleanup always happens
        console.log('Loading:', isLoading);  // false
    });

⚑ Promise Static Methods

Besides creating promises with new Promise(), JavaScript provides two handy shortcut methods for creating promises that are already settled β€” useful for early returns, testing, and ensuring consistent return types:

βœ… Promise.resolve(value)

Creates a promise already fulfilled. Useful for ensuring a function always returns a Promise.

const p = Promise.resolve('Hello');
p.then(v => console.log(v)); // "Hello"

// Ensuring consistent return type
function getConfig(useCache) {
    if (useCache) return Promise.resolve({ theme: 'dark' });
    return fetch('/api/config').then(r => r.json());
}
❌ Promise.reject(reason)

Creates a promise already rejected. Great for early validation/exit.

function getUserById(id) {
    if (!id || typeof id !== 'number')
        return Promise.reject(new Error('Invalid user ID'));
    return fetch(`/api/users/${id}`).then(r => r.json());
}

getUserById(-5)
    .catch(err => console.error(err.message));

πŸ’‘ Key Points:

  • Immediate settlement: Both methods create promises that are already settled (no async work happens)
  • Microtask queue: Even though settled immediately, .then()/.catch() callbacks still run asynchronously (in the next microtask)
  • Passing a Promise: If you pass a Promise to Promise.resolve(), it returns that same promise unchanged

3️⃣ Async/Await: The Modern Way

Async/await is syntactic sugar over promises that makes asynchronous code look and behave like synchronous code. It's the modern, preferred way to work with promises.

Basic Usage Example

Let's start with a practical example to see how async/await works:

// Regular promise-based function
function fetchUserData(userId) {
    return new Promise(resolve => {
        setTimeout(() => resolve({ id: userId, name: 'Alice' }), 1000);
    });
}

// Using async/await (modern way)
async function getUserInfo(userId) {
    console.log('Fetching user...');
    const user = await fetchUserData(userId); // Wait for promise to resolve
    console.log('User:', user);
    return user;  // Automatically wrapped in a Promise!
}

getUserInfo(123);

Understanding the async Keyword

Adding async before a function declaration does one important thing: it makes the function always return a Promise. Even if you return a plain value like a string, JavaScript automatically wraps it in Promise.resolve(). Here are the three rules to remember:

// Rule 1: async function ALWAYS returns a Promise
async function example1() {
    return 'Hello'; // Automatically wrapped in Promise.resolve('Hello')
}

example1().then(result => console.log(result)); // "Hello"

// Rule 2: Even explicit values become promises
async function example2() {
    return Promise.resolve(42);
}

example2().then(result => console.log(result)); // 42

// Rule 3: async without await is unnecessary (but valid)
async function example3() {
    console.log('No await here'); // Just a normal function that returns Promise
    return 'Done';
}

example3();

Understanding the await Keyword

The await keyword pauses the execution of an async function until the awaited Promise resolves. It can only be used inside an async function (or at the top level of ES modules). Think of it as saying "wait here for the result before continuing to the next line":

// Rule 1: await can ONLY be used inside async functions
async function mustBeAsync() {
    const result = await Promise.resolve('Valid');
    console.log(result); // "Valid"
}

// ❌ This would be an ERROR (uncomment to see):
// function notAsync() {
//     const result = await Promise.resolve('Invalid'); // SyntaxError!
// }

// Rule 2: await pauses the function until Promise resolves
async function sequential() {
    console.log('Start');
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log('After 1 second'); // Waits here
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log('After 2 seconds'); // Waits here too
}

sequential();

// Rule 3: You can have async without await (but not await without async)
async function noAwait() {
    return 'No await needed here!'; // Still valid
}

noAwait().then(console.log);

Error Handling with try/catch

With async/await, error handling becomes intuitive β€” you use the same try/catch blocks you already know from synchronous code. If an awaited promise rejects, the error is thrown and caught by the catch block, just like a regular exception:

function fetchData(shouldFail) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (shouldFail) {
                reject(new Error('API failed!'));
            } else {
                resolve({ data: 'Success!' });
            }
        }, 500);
    });
}

// Modern error handling with try/catch
async function handleData(shouldFail) {
    try {
        const result = await fetchData(shouldFail);
        console.log('Got data:', result);
        return result;
    } catch (error) {
        console.error('Error caught:', error.message);
        return { data: 'Fallback data' };  // Recovery!
    }
}

// Test both success and failure
handleData(false); // Success
handleData(true);  // Error caught and handled
πŸ’‘ Key Takeaways:
  • async function always returns a Promise
  • await can only be used inside async functions (or at top-level in ES modules since ES2022)
  • async without await is valid but usually unnecessary
  • try/catch for error handling (cleaner than .catch())
  • Locally blocking, globally non-blocking! await pauses only its async function, but other code/events continue running

⚑ Promise Combinators

Sequential vs Parallel Execution

One of the most impactful performance decisions in async code is whether to run operations sequentially (one after another) or in parallel (all at once). When tasks are independent of each other, running them in parallel can dramatically reduce total execution time:

// ❌ SLOW: Sequential with await (3 seconds total)
async function sequentialFetch() {
    const user1 = await fetchUser(1);  // Wait 1s
    const user2 = await fetchUser(2);  // Wait 1s
    const user3 = await fetchUser(3);  // Wait 1s
    // Total: ~3000ms
}

// βœ… FAST: Parallel with Promise.all (1 second total)
async function parallelFetch() {
    const users = await Promise.all([
        fetchUser(1),
        fetchUser(2),
        fetchUser(3)
    ]);
    // Total: ~1000ms (3x faster!)
}

⚠️ Performance Trap: Using await in a loop or sequentially for independent operations is slow! If tasks don't depend on each other, use Promise.all() to run them concurrently.

Promise.all(promiseArray) β€” Wait for All

Takes: An array of promises

Returns: A promise that resolves with an array of all resolved values (same order)

Behavior: Runs all promises concurrently. If any rejects, the entire operation fails immediately.

function apiCall(id, delay) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id === 3) reject(new Error(`API ${id} failed!`));
            else resolve({ id, data: `Result ${id}` });
        }, delay);
    });
}

async function usePromiseAll() {
    try {
        const results = await Promise.all([
            apiCall(1, 500),
            apiCall(2, 1000),
            // apiCall(3, 200)  // Uncomment to see it fail
        ]);
        console.log('All succeeded:', results);
    } catch (error) {
        console.error('One failed, all rejected:', error.message);
    }
}
usePromiseAll();

Promise.allSettled(promiseArray) β€” Get All Results

Takes: An array of promises

Returns: A promise with an array of result objects: {status: 'fulfilled', value} or {status: 'rejected', reason}

Behavior: Runs all concurrently. Never rejects β€” waits for all to finish regardless of success/failure.

async function usePromiseAllSettled() {
    const results = await Promise.allSettled([
        Promise.resolve('Success 1'),
        Promise.reject(new Error('Failed 2')),
        Promise.resolve('Success 3')
    ]);

    results.forEach((result, i) => {
        if (result.status === 'fulfilled')
            console.log(`${i}: βœ…`, result.value);
        else
            console.log(`${i}: ❌`, result.reason.message);
    });
}
usePromiseAllSettled();

Promise.race(promiseArray) β€” First One Wins

Takes: An array of promises

Returns: A promise that resolves/rejects with the first settled promise's value/reason

Behavior: Returns as soon as ANY promise settles (resolve OR reject). Others are ignored.

function slowAPI() {
    return new Promise(resolve =>
        setTimeout(() => resolve('Slow API (2s)'), 2000)
    );
}
function fastAPI() {
    return new Promise(resolve =>
        setTimeout(() => resolve('Fast API (500ms)'), 500)
    );
}

// First to finish wins!
async function usePromiseRace() {
    const winner = await Promise.race([slowAPI(), fastAPI()]);
    console.log('Winner:', winner);  // "Fast API (500ms)"
}

// πŸ’‘ Common pattern: Timeout with Promise.race
async function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Timeout!')), ms)
    );
    return Promise.race([promise, timeout]);
}

// Test: slowAPI takes 2s, but timeout is 1s
withTimeout(slowAPI(), 1000)
    .then(result => console.log('Success:', result))
    .catch(error => console.error(error.message));  // "Timeout!"

usePromiseRace();

Promise.any(promiseArray) β€” First Success Wins

Takes: An array of promises

Returns: A promise that resolves with the first fulfilled promise's value

Behavior: Returns as soon as ANY promise fulfills. Ignores rejections unless ALL reject (then throws AggregateError).

async function usePromiseAny() {
    try {
        const result = await Promise.any([
            Promise.reject(new Error('Failed 1')),
            Promise.resolve('Success 2'),
            Promise.reject(new Error('Failed 3'))
        ]);
        console.log('First success:', result);  // "Success 2"
    } catch (error) {
        // AggregateError β€” only if ALL promises reject
        console.error('All failed:', error);
    }
}
usePromiseAny();

πŸ“Š Combinators Quick Comparison

  • Promise.all() β€” Waits for all, fails if any fails ⚑ Best for: All tasks must succeed
  • Promise.allSettled() β€” Waits for all, never fails πŸ“Š Best for: Need all results regardless
  • Promise.race() β€” First to finish (success or failure) 🏁 Best for: Timeouts, fastest response
  • Promise.any() β€” First success wins 🎯 Best for: Fallback APIs, redundancy

πŸ”¬ Microtask Queue: Promise callbacks (.then(), .catch(), .finally()) run in the microtask queue, which has higher priority than regular tasks like setTimeout. This means promise handlers always execute before any pending timers, even if the timer was set first.

πŸ“Š Callbacks vs Promises vs Async/Await

Now that we've covered all three async patterns, let's compare them side by side. This table highlights the key differences to help you decide which pattern to use in different situations:

πŸ›‘οΈ
Error Handling
Callbacks: Manual if (err) in every callback
Promises: Single .catch() for entire chain
Async/Await: try/catch block (most readable)
πŸ“–
Readability
Callbacks: ❌ Pyramid of doom
Promises: βœ… Flat .then() chains
Async/Await: βœ…βœ… Looks like synchronous code
⚑
Parallel Operations
Callbacks: Complex coordination needed
Promises: Promise.all([p1, p2, p3])
Async/Await: await Promise.all([p1, p2, p3])
πŸ”—
Sequential Operations
Callbacks: Deep nesting required
Promises: .then().then().then()
Async/Await: const a = await x; const b = await y;
🌐
Browser Support
Callbacks: βœ… All browsers (original JS)
Promises: βœ… ES2015+ (IE11 needs polyfill)
Async/Await: βœ… ES2017+ (all modern browsers)

⚠️ Common Mistakes to Avoid

  1. Forgetting return in .then():
    // ❌ BAD - forgot to return promise
    promise.then(x => { fetchNext(x); })  // Promise lost!
        .then(y => console.log(y));       // y is undefined
    
    // βœ… GOOD - returns promise so chain waits
    promise.then(x => fetchNext(x))       // Implicit return
        .then(y => console.log(y));
  2. Sequential await when parallel is possible:
    // ❌ SLOW - waits 200ms + 200ms = 400ms
    const a = await fetchA(); // 200ms
    const b = await fetchB(); // 200ms
    
    // βœ… FAST - waits max(200ms, 200ms) = 200ms
    const [a, b] = await Promise.all([fetchA(), fetchB()]);
  3. Not handling errors:
    // ❌ BAD - unhandled rejection!
    async function bad() {
        const data = await fetchData(); // If this fails, app crashes
    }
    
    // βœ… GOOD
    async function good() {
        try {
            const data = await fetchData();
        } catch (error) {
            console.error('Handled:', error);
        }
    }
  4. Using async without await: If you don't use await inside an async function, you don't need async!
  5. Forgetting await keyword:
    // ❌ BAD - missing await, user is a Promise object!
    const user = fetchUser(); // Promise { pending }
    
    // βœ… GOOD
    const user = await fetchUser(); // { id: 1, name: 'Alice' }

🎯 When to Use What?

✨Use Async/Await for new code β€” most readable and maintainable
βš™οΈUse Promises when you need fine-grained control or mixing with older code
⚠️Avoid Callbacks for new async code β€” use only when required by libraries
πŸ’‘
Remember:

Synchronous code runs top-to-bottom and blocks on each step. Asynchronous code schedules work and continues; results arrive later via callbacks or promises. async/await makes promise code read like sync, but it's still non-blocking β€” the event loop continues, UI stays responsive.

πŸ“ Summary

This module covered the tools you'll use every day as a JavaScript developer. You now know how to spread and destructure data cleanly, format strings with template literals, exchange data with APIs using JSON, transform arrays with functional methods, handle errors gracefully with try/catch, and write non-blocking code with Promises and async/await. These aren't just nice-to-know features β€” they're the foundation of every modern JavaScript codebase. In the next module, you'll apply these skills to the Browser & DOM.

πŸ”„ Spread/Rest

... expands (spread) or collects (rest). Shallow copy, merge, collect args.

πŸ“ Template Literals

Backticks + ${expr}. Multi-line strings, string interpolation.

πŸ“¦ Destructuring

Extract values from arrays/objects. Rename, defaults, nested.

πŸ“‹ JSON

stringify / parse. Universal data format for APIs.

πŸ“š Array Methods

map, filter, reduce, find. Chain them for powerful data transforms.

πŸ›‘οΈ Error Handling

try/catch/finally. Throw custom errors. Catch at function boundaries.

πŸ“ž Callbacks

The original async pattern. Leads to callback hell β€” avoid for new code.

⏳ Promises & Async/Await

Pending β†’ Fulfilled/Rejected. async/await for readable async code. Promise.all() for parallel.

3
Next Module 3 / 7

Module 3: Browser & DOM

The Document Object Model, selectors, events, DOM manipulation, script loading, and AJAX.

DOM selectors events delegation fetch XHR
β†’