JavaScript Intermediate (Part 2)

Learn advanced JavaScript concepts including asynchronous programming, Fetch API, ES6+ features, and modules.

JavaScript Intermediate Content

This content has been divided into two parts for better readability:

Asynchronous JavaScript

Asynchronous programming is a technique that enables your program to start a potentially long-running task and still be able to be responsive to other events while that task runs, rather than having to wait until that task has finished.

Callbacks

A callback is a function passed as an argument to another function, which is then invoked inside the outer function to complete some kind of action.

// Simple callback example
function greet(name, callback) {
    console.log("Hello " + name);
    callback();
}

function sayGoodbye() {
    console.log("Goodbye!");
}

greet("John", sayGoodbye);
// Output:
// Hello John
// Goodbye!

// Asynchronous callback example
setTimeout(function() {
    console.log("This message is shown after 2 seconds");
}, 2000);

console.log("This message is shown immediately");
// Output:
// This message is shown immediately
// This message is shown after 2 seconds (after 2 seconds)

Callback Hell

Callback hell (also known as the pyramid of doom) is a common problem in asynchronous programming where multiple nested callbacks make the code difficult to read and maintain.

// Callback hell example
getData(function(a) {
    getMoreData(a, function(b) {
        getEvenMoreData(b, function(c) {
            getTheFinalData(c, function(d) {
                console.log(d);
            }, errorCallback);
        }, errorCallback);
    }, errorCallback);
}, errorCallback);

Promises

Promises provide a cleaner way to handle asynchronous operations. A Promise represents a value that may not be available yet but will be resolved at some point in the future.

// Creating a promise
const myPromise = new Promise((resolve, reject) => {
    // Asynchronous operation
    const success = true;
    
    if (success) {
        resolve("Operation successful!");
    } else {
        reject("Operation failed!");
    }
});

// Using a promise
myPromise
    .then(result => {
        console.log(result); // "Operation successful!"
    })
    .catch(error => {
        console.error(error);
    })
    .finally(() => {
        console.log("Promise completed, regardless of outcome");
    });

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

delay(2000)
    .then(() => {
        console.log("Executed after 2 seconds");
        return delay(1000);
    })
    .then(() => {
        console.log("Executed after another 1 second");
    });

Promise Methods

The Promise API provides several static methods for working with promises:

Method Description
Promise.all() Takes an array of promises and returns a new promise that fulfills when all input promises have fulfilled, or rejects if any input promise rejects
Promise.race() Takes an array of promises and returns a new promise that fulfills or rejects as soon as one of the input promises fulfills or rejects
Promise.allSettled() Takes an array of promises and returns a new promise that fulfills when all input promises have settled (either fulfilled or rejected)
Promise.any() Takes an array of promises and returns a new promise that fulfills as soon as one of the input promises fulfills
Promise.resolve() Returns a new promise that is resolved with the given value
Promise.reject() Returns a new promise that is rejected with the given reason
// Promise.all()
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve) => {
    setTimeout(resolve, 1000, 'foo');
});

Promise.all([promise1, promise2, promise3])
    .then((values) => {
        console.log(values); // [3, 42, "foo"]
    });

// Promise.race()
const promise4 = new Promise((resolve) => {
    setTimeout(resolve, 500, 'fast');
});
const promise5 = new Promise((resolve) => {
    setTimeout(resolve, 1000, 'slow');
});

Promise.race([promise4, promise5])
    .then((value) => {
        console.log(value); // "fast"
    });

// Promise.allSettled()
const promise6 = Promise.resolve(1);
const promise7 = Promise.reject('error');
const promise8 = Promise.resolve(3);

Promise.allSettled([promise6, promise7, promise8])
    .then((results) => {
        console.log(results);
        // [
        //   { status: "fulfilled", value: 1 },
        //   { status: "rejected", reason: "error" },
        //   { status: "fulfilled", value: 3 }
        // ]
    });

Async/Await

Async/await is a syntactic sugar on top of promises that makes asynchronous code look and behave more like synchronous code.

// Async function declaration
async function fetchData() {
    try {
        // The await keyword pauses execution until the promise is resolved
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('Error fetching data:', error);
        throw error;
    }
}

// Using an async function
fetchData()
    .then(data => {
        console.log('Data:', data);
    })
    .catch(error => {
        console.error('Error:', error);
    });

// Async arrow function
const fetchUsers = async () => {
    try {
        const response = await fetch('https://api.example.com/users');
        const users = await response.json();
        return users;
    } catch (error) {
        console.error('Error fetching users:', error);
        throw error;
    }
};

// Parallel execution with async/await
async function fetchMultipleResources() {
    try {
        // Start both fetches in parallel
        const userPromise = fetch('https://api.example.com/users');
        const postsPromise = fetch('https://api.example.com/posts');
        
        // Wait for both to complete
        const [userResponse, postsResponse] = await Promise.all([userPromise, postsPromise]);
        
        // Parse the JSON from both responses
        const users = await userResponse.json();
        const posts = await postsResponse.json();
        
        return { users, posts };
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}

Error Handling in Asynchronous Code

Proper error handling is crucial in asynchronous code to prevent unhandled promise rejections and to provide meaningful feedback to users.

// Error handling with promises
fetchData()
    .then(data => {
        // Handle success
        console.log('Data:', data);
    })
    .catch(error => {
        // Handle error
        console.error('Error:', error);
        // Display error message to user
        showErrorMessage('Failed to fetch data. Please try again later.');
    })
    .finally(() => {
        // Clean up (always executed)
        hideLoadingSpinner();
    });

// Error handling with async/await
async function fetchDataWithErrorHandling() {
    try {
        const data = await fetchData();
        // Handle success
        console.log('Data:', data);
        return data;
    } catch (error) {
        // Handle error
        console.error('Error:', error);
        // Display error message to user
        showErrorMessage('Failed to fetch data. Please try again later.');
        throw error; // Re-throw if needed
    } finally {
        // Clean up (always executed)
        hideLoadingSpinner();
    }
}

// Helper functions
function showErrorMessage(message) {
    const errorElement = document.getElementById('error-message');
    errorElement.textContent = message;
    errorElement.style.display = 'block';
}

function hideLoadingSpinner() {
    document.getElementById('loading-spinner').style.display = 'none';
}

Fetch API

The Fetch API provides a modern interface for making HTTP requests. It returns promises, making it a cleaner alternative to XMLHttpRequest.

Basic Usage

// Basic GET request
fetch('https://api.example.com/data')
    .then(response => {
        // Check if the request was successful
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        return response.json(); // Parse JSON response
    })
    .then(data => {
        console.log('Data:', data);
    })
    .catch(error => {
        console.error('Error:', error);
    });

// Using async/await
async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const data = await response.json();
        console.log('Data:', data);
        return data;
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}

Request Options

The fetch() function accepts a second parameter, an options object that allows you to customize the request.

// POST request with JSON data
fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer your-token-here'
    },
    body: JSON.stringify({
        name: 'John Doe',
        email: '[email protected]'
    })
})
.then(response => response.json())
.then(data => console.log('Success:', data))
.catch(error => console.error('Error:', error));

// PUT request
fetch('https://api.example.com/users/1', {
    method: 'PUT',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        name: 'Jane Doe',
        email: '[email protected]'
    })
})
.then(response => response.json())
.then(data => console.log('Success:', data))
.catch(error => console.error('Error:', error));

// DELETE request
fetch('https://api.example.com/users/1', {
    method: 'DELETE',
    headers: {
        'Authorization': 'Bearer your-token-here'
    }
})
.then(response => {
    if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
})
.then(data => console.log('Success:', data))
.catch(error => console.error('Error:', error));

Response Object

The Response object represents the response to a request. It provides various methods and properties to handle the response data.

fetch('https://api.example.com/data')
    .then(response => {
        // Response properties
        console.log('Status:', response.status); // HTTP status code (e.g., 200, 404)
        console.log('Status Text:', response.statusText); // HTTP status text (e.g., "OK", "Not Found")
        console.log('OK?', response.ok); // Boolean, true if status is 200-299
        console.log('Content Type:', response.headers.get('content-type'));
        console.log('URL:', response.url);
        
        // Response methods for parsing the body
        // Note: You can only use ONE of these methods per response
        // return response.json(); // Parse as JSON
        // return response.text(); // Parse as text
        // return response.blob(); // Parse as Blob (for binary data)
        // return response.formData(); // Parse as FormData
        // return response.arrayBuffer(); // Parse as ArrayBuffer
        
        return response.json();
    })
    .then(data => {
        console.log('Data:', data);
    })
    .catch(error => {
        console.error('Error:', error);
    });

Handling Different Response Types

// Handling JSON response
fetch('https://api.example.com/users')
    .then(response => response.json())
    .then(users => console.log('Users:', users));

// Handling text response
fetch('https://example.com/page.html')
    .then(response => response.text())
    .then(html => document.getElementById('content').innerHTML = html);

// Handling binary data (e.g., image)
fetch('https://example.com/image.jpg')
    .then(response => response.blob())
    .then(blob => {
        const imageUrl = URL.createObjectURL(blob);
        const imageElement = document.createElement('img');
        imageElement.src = imageUrl;
        document.body.appendChild(imageElement);
    });

// Handling form data
fetch('https://example.com/form-data')
    .then(response => response.formData())
    .then(formData => {
        console.log('Form data:', formData.get('field-name'));
    });

Aborting Fetch Requests

The AbortController interface allows you to abort one or more fetch requests as needed.

// Create an AbortController instance
const controller = new AbortController();
const signal = controller.signal;

// Start the fetch request
fetch('https://api.example.com/data', { signal })
    .then(response => response.json())
    .then(data => console.log('Data:', data))
    .catch(error => {
        if (error.name === 'AbortError') {
            console.log('Fetch aborted');
        } else {
            console.error('Error:', error);
        }
    });

// Abort the fetch after 5 seconds
setTimeout(() => {
    controller.abort();
    console.log('Fetch aborted after timeout');
}, 5000);

Fetch with Async/Await

Using async/await with fetch makes asynchronous code more readable and easier to maintain.

async function fetchUserData(userId) {
    try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const userData = await response.json();
        return userData;
    } catch (error) {
        console.error('Error fetching user data:', error);
        throw error;
    }
}

async function fetchUserPosts(userId) {
    try {
        const response = await fetch(`https://api.example.com/users/${userId}/posts`);
        
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const posts = await response.json();
        return posts;
    } catch (error) {
        console.error('Error fetching user posts:', error);
        throw error;
    }
}

// Using multiple fetch calls
async function getUserWithPosts(userId) {
    try {
        // Get user data and posts in parallel
        const [userData, userPosts] = await Promise.all([
            fetchUserData(userId),
            fetchUserPosts(userId)
        ]);
        
        // Combine the results
        return {
            user: userData,
            posts: userPosts
        };
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}

// Usage
getUserWithPosts(1)
    .then(data => {
        console.log('User:', data.user);
        console.log('Posts:', data.posts);
    })
    .catch(error => {
        console.error('Failed to get user with posts:', error);
    });

ES6+ Features

ECMAScript 6 (ES6), also known as ECMAScript 2015, introduced many new features to JavaScript. Subsequent versions (ES7, ES8, ES9, etc.) have continued to add new functionality.

Arrow Functions

Arrow functions provide a shorter syntax for writing function expressions and do not have their own this, arguments, super, or new.target.

// Traditional function expression
const add = function(a, b) {
    return a + b;
};

// Arrow function
const addArrow = (a, b) => a + b;

// Arrow function with multiple statements
const calculate = (a, b) => {
    const sum = a + b;
    const product = a * b;
    return { sum, product };
};

// Arrow function with a single parameter (parentheses optional)
const square = x => x * x;

// Arrow function with no parameters
const sayHello = () => "Hello!";

// Arrow functions and this
function Counter() {
    this.count = 0;
    
    // Traditional function loses 'this' context
    setInterval(function() {
        this.count++; // 'this' refers to the global object, not Counter
        console.log(this.count); // NaN
    }, 1000);
    
    // Arrow function preserves 'this' context
    setInterval(() => {
        this.count++; // 'this' refers to the Counter instance
        console.log(this.count); // 1, 2, 3, ...
    }, 1000);
}

Template Literals

Template literals are string literals that allow embedded expressions and multi-line strings.

// Basic template literal
const name = "John";
const greeting = `Hello, ${name}!`;
console.log(greeting); // "Hello, John!"

// Multi-line strings
const multiLine = `This is a
multi-line
string`;
console.log(multiLine);
// "This is a
// multi-line
// string"

// Expressions in template literals
const a = 5;
const b = 10;
console.log(`The sum of ${a} and ${b} is ${a + b}`);
// "The sum of 5 and 10 is 15"

// Tagged templates
function highlight(strings, ...values) {
    return strings.reduce((result, string, i) => {
        return `${result}${string}${values[i] ? `${values[i]}` : ''}`;
    }, '');
}

const name2 = "Alice";
const age = 28;
const output = highlight`My name is ${name2} and I am ${age} years old.`;
console.log(output);
// "My name is Alice and I am 28 years old."

Destructuring Assignment

Destructuring assignment is a syntax that allows you to extract data from arrays or objects into distinct variables.

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

// Skipping elements
const [a, , c] = numbers;
console.log(a); // 1
console.log(c); // 3

// Default values
const [x = 0, y = 0, z = 0] = [1, 2];
console.log(x, y, z); // 1, 2, 0

// Swapping variables
let m = 1;
let n = 2;
[m, n] = [n, m];
console.log(m, n); // 2, 1

// Object destructuring
const person = {
    name: "John",
    age: 30,
    city: "New York",
    country: "USA"
};

const { name, age } = person;
console.log(name); // "John"
console.log(age); // 30

// Assigning to new variable names
const { name: fullName, age: years } = person;
console.log(fullName); // "John"
console.log(years); // 30

// Default values
const { name: userName, job = "Unknown" } = person;
console.log(userName); // "John"
console.log(job); // "Unknown"

// Nested destructuring
const user = {
    id: 1,
    name: "John",
    address: {
        street: "123 Main St",
        city: "New York",
        country: "USA"
    }
};

const { address: { city: userCity, country: userCountry } } = user;
console.log(userCity); // "New York"
console.log(userCountry); // "USA"

// Function parameter destructuring
function printUserInfo({ name, age, address: { city } = {} } = {}) {
    console.log(`${name}, ${age}, ${city}`);
}

printUserInfo(user); // "John, 30, New York"

Spread and Rest Operators

The spread operator (...) allows an iterable to be expanded in places where zero or more arguments or elements are expected. The rest parameter syntax allows a function to accept an indefinite number of arguments as an array.

// Spread operator with arrays
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

// Combine arrays
const combined = [...arr1, ...arr2];
console.log(combined); // [1, 2, 3, 4, 5, 6]

// Copy an array
const copy = [...arr1];
console.log(copy); // [1, 2, 3]

// Spread in function calls
function sum(a, b, c) {
    return a + b + c;
}

console.log(sum(...arr1)); // 6 (1 + 2 + 3)

// Spread operator with objects
const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };

// Combine objects
const combinedObj = { ...obj1, ...obj2 };
console.log(combinedObj); // { a: 1, b: 2, c: 3, d: 4 }

// Copy an object
const objCopy = { ...obj1 };
console.log(objCopy); // { a: 1, b: 2 }

// Override properties
const overridden = { ...obj1, b: 5 };
console.log(overridden); // { a: 1, b: 5 }

// Rest parameter in functions
function sumAll(...numbers) {
    return numbers.reduce((total, num) => total + num, 0);
}

console.log(sumAll(1, 2, 3, 4, 5)); // 15

// Rest parameter with other parameters
function multiply(multiplier, ...numbers) {
    return numbers.map(num => num * multiplier);
}

console.log(multiply(2, 1, 2, 3)); // [2, 4, 6]

// Rest in destructuring
const [first, ...others] = [1, 2, 3, 4, 5];
console.log(first); // 1
console.log(others); // [2, 3, 4, 5]

const { a, ...rest } = { a: 1, b: 2, c: 3 };
console.log(a); // 1
console.log(rest); // { b: 2, c: 3 }

Default Parameters

Default function parameters allow named parameters to be initialized with default values if no value or undefined is passed.

// Basic default parameters
function greet(name = "Guest", greeting = "Hello") {
    return `${greeting}, ${name}!`;
}

console.log(greet()); // "Hello, Guest!"
console.log(greet("John")); // "Hello, John!"
console.log(greet("Jane", "Hi")); // "Hi, Jane!"

// Default parameters with expressions
function calculateTax(price, taxRate = 0.1, shipping = price > 100 ? 0 : 10) {
    return price + (price * taxRate) + shipping;
}

console.log(calculateTax(50)); // 65 (50 + 5 + 10)
console.log(calculateTax(200)); // 220 (200 + 20 + 0)
console.log(calculateTax(100, 0.2, 15)); // 135 (100 + 20 + 15)

// Default parameters with functions
function getDefaultName() {
    return "Anonymous";
}

function welcome(name = getDefaultName()) {
    return `Welcome, ${name}!`;
}

console.log(welcome()); // "Welcome, Anonymous!"
console.log(welcome("John")); // "Welcome, John!"

Classes

ES6 introduced a new syntax for creating classes, although JavaScript remains prototype-based. The class syntax is just syntactic sugar over JavaScript's existing prototype-based inheritance.

// Class declaration
class Person {
    // Constructor method
    constructor(firstName, lastName, age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }
    
    // Instance method
    getFullName() {
        return `${this.firstName} ${this.lastName}`;
    }
    
    // Static method
    static createAnonymous() {
        return new Person("John", "Doe", 30);
    }
    
    // Getter
    get name() {
        return this.getFullName();
    }
    
    // Setter
    set name(fullName) {
        const parts = fullName.split(" ");
        this.firstName = parts[0];
        this.lastName = parts[1] || "";
    }
}

// Creating an instance
const john = new Person("John", "Doe", 30);
console.log(john.getFullName()); // "John Doe"

// Using static method
const anonymous = Person.createAnonymous();
console.log(anonymous.getFullName()); // "John Doe"

// Using getter
console.log(john.name); // "John Doe"

// Using setter
john.name = "Jane Smith";
console.log(john.firstName); // "Jane"
console.log(john.lastName); // "Smith"

// Inheritance
class Employee extends Person {
    constructor(firstName, lastName, age, position, salary) {
        // Call parent constructor
        super(firstName, lastName, age);
        this.position = position;
        this.salary = salary;
    }
    
    // Override parent method
    getFullName() {
        return `${super.getFullName()} (${this.position})`;
    }
    
    // New method
    getAnnualSalary() {
        return this.salary * 12;
    }
}

const jane = new Employee("Jane", "Smith", 28, "Developer", 5000);
console.log(jane.getFullName()); // "Jane Smith (Developer)"
console.log(jane.getAnnualSalary()); // 60000

Enhanced Object Literals

ES6 introduced several enhancements to object literals, making them more powerful and concise.

// Property shorthand
const name = "John";
const age = 30;

// Old way
const person1 = {
    name: name,
    age: age
};

// New way (property shorthand)
const person2 = { name, age };
console.log(person2); // { name: "John", age: 30 }

// Method shorthand
// Old way
const obj1 = {
    sayHello: function() {
        return "Hello!";
    }
};

// New way (method shorthand)
const obj2 = {
    sayHello() {
        return "Hello!";
    }
};

console.log(obj2.sayHello()); // "Hello!"

// Computed property names
const propName = "dynamicProp";

const obj3 = {
    [propName]: "Dynamic value",
    [`computed_${propName}`]: 42
};

console.log(obj3.dynamicProp); // "Dynamic value"
console.log(obj3.computed_dynamicProp); // 42

// Combining all features
const prefix = "user";
const userCount = 42;

const users = {
    [prefix + "Count"]: userCount,
    [`get${prefix.charAt(0).toUpperCase() + prefix.slice(1)}Count`]() {
        return this[prefix + "Count"];
    },
    increment() {
        this[prefix + "Count"]++;
    }
};

console.log(users.userCount); // 42
console.log(users.getUserCount()); // 42
users.increment();
console.log(users.getUserCount()); // 43

Map and Set

ES6 introduced new data structures: Map, Set, WeakMap, and WeakSet.

// Map
const map = new Map();

// Adding entries
map.set("name", "John");
map.set("age", 30);
map.set(1, "Number one");
map.set(true, "Boolean value");
map.set({ key: "object" }, "Object as key");

// Getting values
console.log(map.get("name")); // "John"
console.log(map.get(1)); // "Number one"

// Checking if a key exists
console.log(map.has("age")); // true
console.log(map.has("email")); // false

// Size
console.log(map.size); // 5

// Deleting entries
map.delete("age");
console.log(map.size); // 4

// Iterating over a Map
// Entries
for (const [key, value] of map.entries()) {
    console.log(`${key} = ${value}`);
}

// Keys
for (const key of map.keys()) {
    console.log(key);
}

// Values
for (const value of map.values()) {
    console.log(value);
}

// forEach
map.forEach((value, key) => {
    console.log(`${key} = ${value}`);
});

// Clearing the Map
map.clear();
console.log(map.size); // 0

// Set
const set = new Set();

// Adding values
set.add(1);
set.add("text");
set.add(true);
set.add({ key: "object" });
set.add(1); // Duplicate, will be ignored

// Size
console.log(set.size); // 4

// Checking if a value exists
console.log(set.has(1)); // true
console.log(set.has(2)); // false

// Deleting values
set.delete("text");
console.log(set.size); // 3

// Iterating over a Set
for (const value of set) {
    console.log(value);
}

// forEach
set.forEach(value => {
    console.log(value);
});

// Converting Set to Array
const array = Array.from(set);
// or
const array2 = [...set];

// Clearing the Set
set.clear();
console.log(set.size); // 0

// WeakMap and WeakSet
// Keys in WeakMap and values in WeakSet must be objects
// They don't prevent garbage collection of their keys/values
// They are not iterable and don't have size property

const weakMap = new WeakMap();
let obj = { key: "value" };
weakMap.set(obj, "metadata");
console.log(weakMap.get(obj)); // "metadata"

const weakSet = new WeakSet();
let obj2 = { key: "value" };
weakSet.add(obj2);
console.log(weakSet.has(obj2)); // true

Symbols

Symbol is a primitive data type introduced in ES6. Symbols are unique and immutable, and can be used as object property keys.

// Creating symbols
const sym1 = Symbol();
const sym2 = Symbol("description");
const sym3 = Symbol("description"); // Each Symbol is unique

console.log(sym2 === sym3); // false, even with the same description

// Using symbols as object keys
const obj = {
    [sym1]: "Value for sym1",
    [sym2]: "Value for sym2",
    regularKey: "Regular value"
};

console.log(obj[sym1]); // "Value for sym1"
console.log(obj[sym2]); // "Value for sym2"

// Symbols are not enumerable in for...in loops
for (const key in obj) {
    console.log(key); // Only "regularKey" is logged
}

// Object.keys() also ignores Symbol keys
console.log(Object.keys(obj)); // ["regularKey"]

// To get Symbol properties
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(), Symbol(description)]

// Well-known symbols
// These are built-in Symbol values that are used by JavaScript internally

// Symbol.iterator - Makes an object iterable
const iterableObj = {
    data: [1, 2, 3, 4],
    [Symbol.iterator]() {
        let index = 0;
        return {
            next: () => {
                if (index < this.data.length) {
                    return { value: this.data[index++], done: false };
                } else {
                    return { done: true };
                }
            }
        };
    }
};

for (const value of iterableObj) {
    console.log(value); // 1, 2, 3, 4
}

// Symbol.toStringTag - Customizes the result of Object.prototype.toString
class CustomClass {
    get [Symbol.toStringTag]() {
        return "CustomClass";
    }
}

const instance = new CustomClass();
console.log(Object.prototype.toString.call(instance)); // "[object CustomClass]"

JavaScript Modules

ES6 introduced a standardized module system for JavaScript. Modules allow you to split your code into separate files, making it more maintainable and reusable.

Exporting

You can export functions, objects, or primitive values from a module.

// Named exports (math.js)
export const PI = 3.14159;

export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

// Alternative syntax for named exports
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

export { multiply, divide };

// Renaming exports
function square(x) {
    return x * x;
}

export { square as squareNumber };

// Default export (only one per module)
export default class Calculator {
    add(a, b) {
        return a + b;
    }
    
    subtract(a, b) {
        return a - b;
    }
}

Importing

You can import functionality from other modules.

// Importing named exports
import { add, subtract, PI } from './math.js';

console.log(add(2, 3)); // 5
console.log(subtract(5, 2)); // 3
console.log(PI); // 3.14159

// Importing with aliases
import { add as sum, subtract as difference } from './math.js';

console.log(sum(2, 3)); // 5
console.log(difference(5, 2)); // 3

// Importing default export
import Calculator from './math.js';

const calc = new Calculator();
console.log(calc.add(2, 3)); // 5

// Importing default and named exports
import Calculator, { PI, add } from './math.js';

// Importing all exports as an object
import * as MathModule from './math.js';

console.log(MathModule.PI); // 3.14159
console.log(MathModule.add(2, 3)); // 5
const calc2 = new MathModule.default();
console.log(calc2.subtract(5, 2)); // 3

Dynamic Imports

Dynamic imports allow you to load modules on demand, which can improve performance by reducing the initial load time.

// Static import (loaded at parse time)
import { add } from './math.js';

// Dynamic import (loaded at runtime)
async function loadMathModule() {
    try {
        // The import() function returns a promise
        const mathModule = await import('./math.js');
        
        console.log(mathModule.add(2, 3)); // 5
        console.log(mathModule.PI); // 3.14159
        
        const calc = new mathModule.default();
        console.log(calc.subtract(5, 2)); // 3
        
        return mathModule;
    } catch (error) {
        console.error('Error loading module:', error);
    }
}

// Conditional loading
const userPreference = 'advanced';

if (userPreference === 'advanced') {
    import('./advanced-features.js')
        .then(module => {
            module.enableAdvancedFeatures();
        })
        .catch(error => {
            console.error('Error loading advanced features:', error);
        });
}

Module Organization

Here's an example of how you might organize a simple application using modules:

// utils.js - Utility functions
export function formatDate(date) {
    return date.toLocaleDateString();
}

export function formatCurrency(amount) {
    return `$${amount.toFixed(2)}`;
}

// api.js - API functions
const API_URL = 'https://api.example.com';

export async function fetchUsers() {
    const response = await fetch(`${API_URL}/users`);
    return response.json();
}

export async function fetchUser(id) {
    const response = await fetch(`${API_URL}/users/${id}`);
    return response.json();
}

// user.js - User-related functionality
import { formatDate } from './utils.js';
import { fetchUser } from './api.js';

export class User {
    constructor(id, name, email, createdAt) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.createdAt = new Date(createdAt);
    }
    
    getFormattedDate() {
        return formatDate(this.createdAt);
    }
    
    static async load(id) {
        const userData = await fetchUser(id);
        return new User(
            userData.id,
            userData.name,
            userData.email,
            userData.created_at
        );
    }
}

// main.js - Main application code
import { formatCurrency } from './utils.js';
import { fetchUsers } from './api.js';
import { User } from './user.js';

async function init() {
    try {
        // Load all users
        const users = await fetchUsers();
        console.log(`Loaded ${users.length} users`);
        
        // Load a specific user
        const user = await User.load(1);
        console.log(`User: ${user.name}`);
        console.log(`Joined: ${user.getFormattedDate()}`);
        
        // Format currency
        console.log(`Balance: ${formatCurrency(125.75)}`);
    } catch (error) {
        console.error('Error initializing app:', error);
    }
}

init();

Using Modules in the Browser

To use ES modules directly in the browser, you need to specify the type="module" attribute on your script tag.

<!-- HTML file -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ES Modules Example</title>
</head>
<body>
    <h1>ES Modules Example</h1>
    
    <!-- Using ES modules in the browser -->
    <script type="module" src="main.js"></script>
    
    <!-- Inline module script -->
    <script type="module">
        import { formatDate } from './utils.js';
        
        const today = new Date();
        console.log(`Today is ${formatDate(today)}`);
    </script>
    
    <!-- Fallback for browsers that don't support modules -->
    <script nomodule>
        console.log('Your browser does not support ES modules');
    </script>
</body>
</html>

Practice Exercises

Now that you've learned intermediate JavaScript concepts, it's time to practice! Here are some exercises to help you reinforce what you've learned.

Exercise 1: Object-Oriented Programming

Create a class hierarchy for a library management system:

  • Create a Book class with properties for title, author, ISBN, and availability status
  • Create a Library class that manages a collection of books
  • Implement methods for adding books, removing books, checking out books, and returning books
  • Use getters and setters to control access to properties
  • Implement proper error handling for operations like checking out an unavailable book

Exercise 2: Asynchronous Programming

Create a weather dashboard application that fetches data from a weather API:

  • Use the Fetch API to retrieve weather data for a given city
  • Implement error handling for failed API requests
  • Use async/await for handling asynchronous operations
  • Display the current weather and a 5-day forecast
  • Add a loading indicator while data is being fetched

Exercise 3: DOM Manipulation

Create a dynamic to-do list application:

  • Allow users to add, edit, and delete tasks
  • Implement filtering options (all, active, completed)
  • Use event delegation for handling events on dynamically created elements
  • Store tasks in localStorage to persist data between page reloads
  • Add animations for adding and removing tasks

Exercise 4: Modular JavaScript

Refactor an existing application to use ES modules:

  • Split the code into logical modules (e.g., UI, data, utilities)
  • Use named and default exports appropriately
  • Implement dynamic imports for features that aren't needed immediately
  • Ensure that the application works the same after refactoring

Exercise 5: Advanced Array Methods

Work with a dataset of products and implement the following operations:

  • Filter products by category, price range, and availability
  • Sort products by price, name, or rating
  • Calculate statistics like average price, highest rated product, etc.
  • Transform the data for display (e.g., formatting prices, generating HTML)
  • Implement a search function that filters products by name or description

Next Steps

Now that you've learned intermediate JavaScript concepts, you can continue your learning journey with:

  • Advanced JavaScript topics like design patterns, functional programming, and performance optimization
  • JavaScript frameworks like React, Vue, or Angular
  • Server-side JavaScript with Node.js
  • Building real-world projects to apply your knowledge