JavaScript Interview Questions

Fundamentals

What is JavaScript?

JavaScript is a high-level, interpreted programming language that conforms to the ECMAScript specification. It is a language that is also characterized as dynamic, weakly typed, prototype-based and multi-paradigm.

Key concepts:

  • Event-driven: JavaScript is designed to respond to events, such as user clicks or key presses.
  • Single-threaded: JavaScript has a single thread of execution, which means it can only do one thing at a time. However, it can be asynchronous, allowing it to handle multiple operations without blocking the main thread.
  • JIT Compilation: Modern JavaScript engines use Just-In-Time (JIT) compilation, which compiles code to machine code at runtime for improved performance.
  • First-class functions: Functions are treated as first-class citizens, meaning they can be assigned to variables, passed as arguments, and returned from other functions.
  • Dynamic typing: Variables are not bound to specific data types, allowing for flexibility but requiring careful coding practices.
const greet = function(name) {
  return `Hello, ${name}!`;
};

const sayHello = greet;
console.log(sayHello('Alice')); // Hello, Alice!

function higherOrder(fn) {
  return function(value) {
    return fn(value) * 2;
  };
}

const doubleResult = higherOrder(x => x + 1);
console.log(doubleResult(5)); // 12
Fundamentals

What are the data types in JavaScript?

JavaScript has eight data types, divided into two categories:

Primitive types (7):

  • Number: Represents both integer and floating-point numbers, including special values like NaN, Infinity, and -Infinity.
  • String: Represents textual data enclosed in single quotes, double quotes, or backticks.
  • Boolean: Represents true or false.
  • Null: Represents the intentional absence of any object value.
  • Undefined: Represents a variable that has been declared but not yet assigned a value.
  • Symbol: Represents a unique identifier, often used as object property keys to avoid naming conflicts.
  • BigInt: Represents integers of arbitrary length, useful for working with numbers larger than Number.MAX_SAFE_INTEGER.

Reference type (1):

  • Object: Represents a collection of key-value pairs, including arrays, functions, dates, and more.
const num = 42;
const str = 'Hello';
const bool = true;
const n = null;
let und;
const sym = Symbol('description');
const bigInt = 9007199254740991n;
const obj = { name: 'John', age: 30 };
const arr = [1, 2, 3];
const func = function() { return 'I am a function'; };

console.log(typeof num); // number
console.log(typeof str); // string
console.log(typeof bool); // boolean
console.log(typeof n); // object (known JavaScript quirk)
console.log(typeof und); // undefined
console.log(typeof sym); // symbol
console.log(typeof bigInt); // bigint
console.log(typeof obj); // object
console.log(typeof arr); // object
console.log(typeof func); // function
Fundamentals

What is the difference between var, let, and const?

The main difference between var, let, and const is their scope, hoisting behavior, and whether they can be reassigned.

  • var: Function-scoped and can be redeclared and reassigned. Variables are hoisted to the top of their scope and initialized with undefined.
  • let: Block-scoped and can be reassigned but not redeclared within the same scope. Variables are hoisted but not initialized, creating a "temporal dead zone" until the declaration is encountered.
  • const: Block-scoped and cannot be reassigned or redeclared. Variables are hoisted but not initialized, creating a "temporal dead zone". For objects and arrays, the reference cannot be changed, but the contents can be modified.
function varExample() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 (function-scoped)
}

function letExample() {
  if (true) {
    let y = 20;
  }
  console.log(y); // ReferenceError: y is not defined (block-scoped)
}

function constExample() {
  const z = 30;
  z = 40; // TypeError: Assignment to constant variable
}

const obj = { a: 1 };
obj.a = 2; // This is allowed - changing property, not reassigning the reference
obj = { b: 3 }; // TypeError: Assignment to constant variable

console.log(a); // undefined (var is hoisted)
var a = 5;

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;
Fundamentals

What are closures in JavaScript?

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function.

Closures are created every time a function is created, at function creation time. They are powerful because they allow functions to maintain access to variables from their outer scope even after the outer function has finished executing.

Common use cases for closures:

  • Data privacy and encapsulation
  • Function factories
  • Callbacks and event handlers
  • Partial application and currying
function outerFunction(x) {
  return function innerFunction(y) {
    return x + y;
  };
}

const addFive = outerFunction(5);
console.log(addFive(3)); // 8

function createCounter() {
  let count = 0;
  return {
    increment: function() { count++; return count; },
    decrement: function() { count--; return count; },
    getCount: function() { return count; }
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.decrement()); // 1

for (var i = 0; i < 3; i++) {
  setTimeout(function() { console.log(i); }, 100);
}
// Output: 3, 3, 3 (var is function-scoped)

for (let i = 0; i < 3; i++) {
  setTimeout(function() { console.log(i); }, 100);
}
// Output: 0, 1, 2 (let is block-scoped, creating a new closure each iteration)}
Fundamentals

What is hoisting in JavaScript?

Hoisting is JavaScript's default behavior of moving declarations to the top of the current scope (to the top of the current script or the current function). This means that you can use variables and functions before they are declared.

However, only the declarations are hoisted, not the initializations. The way hoisting works differs between var, let, const, and function declarations.

  • Function declarations: Fully hoisted, meaning you can call the function before its declaration in the code.
  • var declarations: Hoisted and initialized with undefined.
  • let and const declarations: Hoisted but not initialized, which results in a "temporal dead zone" from the start of the block until the declaration is encountered.
console.log(myVar); // undefined (not ReferenceError)
var myVar = 5;
console.log(myVar); // 5

console.log(myLet); // ReferenceError: Cannot access 'myLet' before initialization
let myLet = 10;

sayHello(); // "Hello!" (function declaration is fully hoisted)
function sayHello() {
  console.log("Hello!");
}

sayHi(); // TypeError: sayHi is not a function (function expression is not fully hoisted)
var sayHi = function() {
  console.log("Hi!");
};

// How the above code is interpreted during hoisting:
var myVar = undefined;
var sayHi = undefined;

function sayHello() {
  console.log("Hello!");
}

console.log(myVar); // undefined
myVar = 5;
console.log(myVar); // 5

console.log(myLet); // ReferenceError
let myLet = 10;

sayHello(); // "Hello!"
sayHi(); // TypeError
sayHi = function() {
  console.log("Hi!");
};
Fundamentals

== vs ===

JavaScript has two types of equality operators: strict equality (===) and loose equality (==). The main difference is that === checks both value and type, while == performs type coercion before comparing values.

  • Strict equality (===): Returns true only if both operands have the same value and the same type. No type conversion is performed.
  • Loose equality (==): Returns true if the operands are equal after type conversion. JavaScript converts the operands to the same type before comparing.

It's generally recommended to use strict equality (===) to avoid unexpected results from type coercion.

console.log(5 === 5); // true
console.log('5' === 5); // false (different types)
console.log(true === 1); // false (different types)
console.log(null === undefined); // false (different types)

console.log(5 == 5); // true
console.log('5' == 5); // true (string '5' is converted to number 5)
console.log(true == 1); // true (boolean true is converted to number 1)
console.log(null == undefined); // true (special case)
console.log(false == 0); // true (boolean false is converted to number 0)
console.log('' == 0); // true (empty string is converted to number 0)
console.log('' == false); // true (empty string is converted to false)

// Special cases with NaN
console.log(NaN === NaN); // false (NaN is not equal to anything, including itself)
console.log(NaN == NaN); // false

// Object.is provides even stricter comparison
console.log(Object.is(NaN, NaN)); // true
console.log(Object.is(0, -0)); // false
console.log(0 === -0); // true
Fundamentals

Truthy and Falsy values

In JavaScript, every value can be evaluated as either truthy or falsy in a boolean context. Falsy values are values that translate to false when evaluated in a boolean context, while all other values are considered truthy.

Falsy values (8 total):

  • false - The boolean false
  • 0 - The number zero
  • -0 - Negative zero
  • 0n - BigInt zero
  • "" - Empty string
  • null - Absence of value
  • undefined - Uninitialized value
  • NaN - Not a Number

Truthy values: All other values, including:

  • All objects (including empty objects and empty arrays [])
  • All non-zero numbers
  • All non-empty strings
  • The boolean true
// Falsy values
if (false) console.log('This will not run');
if (0) console.log('This will not run');
if (-0) console.log('This will not run');
if (0n) console.log('This will not run');
if ("") console.log('This will not run');
if (null) console.log('This will not run');
if (undefined) console.log('This will not run');
if (NaN) console.log('This will not run');

// Truthy values
if (true) console.log('This will run');
if (1) console.log('This will run');
if (-1) console.log('This will run');
if ("0") console.log('This will run'); // Non-empty string
if ("false") console.log('This will run'); // Non-empty string
if ({}) console.log('This will run'); // Empty object
if ([]) console.log('This will run'); // Empty array
if (function() {}) console.log('This will run'); // Empty function

// Using the logical OR operator to provide default values
const name = userInput || 'Guest';
console.log(name); // 'Guest' if userInput is falsy

// Using the logical AND operator for conditional execution
user && console.log('User exists:', user.name);

// Double negation to explicitly convert to boolean
const isTruthy = !!value;
console.log(isTruthy); // true if value is truthy, false if value is falsy
Fundamentals

What is type coercion?

Type coercion is the automatic or implicit conversion of values from one data type to another in JavaScript. This can happen explicitly (when you intentionally convert a type) or implicitly (when JavaScript automatically converts a type during operations).

Explicit type conversion:

  • String(value) - Converts to string
  • Number(value) - Converts to number
  • Boolean(value) - Converts to boolean
  • parseInt(string, radix) - Parses string to integer
  • parseFloat(string) - Parses string to floating-point number

Implicit type conversion:

  • String concatenation with + operator
  • Numeric operations with -, *, /, %
  • Loose equality (==) comparisons
  • Logical operators (&&, ||, !)
  • Conditional statements (if, while, etc.)
// Explicit type conversion
const num = Number('42'); // 42
const str = String(123); // '123'
const bool = Boolean('hello'); // true
const parsedInt = parseInt('42px', 10); // 42
const parsedFloat = parseFloat('3.14'); // 3.14

// Implicit type conversion
const result1 = '5' + 2; // '52' (string concatenation)
const result2 = '5' - 2; // 3 (numeric subtraction)
const result3 = '5' * 2; // 10 (numeric multiplication)
const result4 = '5' / 2; // 2.5 (numeric division)
const result5 = '5' % 2; // 1 (numeric modulo)

// Unary plus operator for numeric conversion
const numeric = +'5'; // 5
const numeric2 = +true; // 1
const numeric3 = +false; // 0
const numeric4 = +null; // 0
const numeric5 = +undefined; // NaN

// Logical operators and type coercion
console.log('hello' && 5); // 5 (both truthy, returns the second)
console.log('hello' || 5); // 'hello' (first truthy, returns the first)
console.log(null || 'default'); // 'default' (first falsy, returns the second)
console.log(0 || 'default'); // 'default' (first falsy, returns the second)

// Type coercion in conditional statements
if ('hello') console.log('This will run'); // Non-empty string is truthy
if (0) console.log('This will not run'); // 0 is falsy
Fundamentals

JSON.parse and JSON.stringify

JSON (JavaScript Object Notation) is a lightweight data interchange format. JavaScript provides two methods for working with JSON data: JSON.parse() to convert JSON strings to JavaScript objects, and JSON.stringify() to convert JavaScript objects to JSON strings.

JSON.parse(text, reviver):

  • Parses a JSON string and returns the corresponding JavaScript object
  • Optional reviver function can transform the parsed values
  • Throws a SyntaxError if the string is not valid JSON

JSON.stringify(value, replacer, space):

  • Converts a JavaScript object to a JSON string
  • Optional replacer function can filter and transform values
  • Optional space parameter adds indentation for readability
const user = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com',
  isActive: true,
  roles: ['admin', 'user'],
  createdAt: new Date(),
  profile: {
    age: 30,
    address: null
  }
};

// Object to JSON string
const jsonString = JSON.stringify(user);
console.log(jsonString);
// '{"id":1,"name":"John Doe","email":"john@example.com","isActive":true,"roles":["admin","user"],"createdAt":"2023-05-15T12:00:00.000Z","profile":{"age":30,"address":null}}'

// With spacing for readability
const prettyJson = JSON.stringify(user, null, 2);
console.log(prettyJson);
// {
//   "id": 1,
//   "name": "John Doe",
//   ...
// }

// Using replacer function
const filteredJson = JSON.stringify(user, (key, value) => {
  if (key === 'email') return undefined; // Exclude email
  return value;
});
console.log(filteredJson);
// '{"id":1,"name":"John Doe","isActive":true,"roles":["admin","user"],"createdAt":"2023-05-15T12:00:00.000Z","profile":{"age":30,"address":null}}'

// JSON string to object
const parsedUser = JSON.parse(jsonString);
console.log(parsedUser.name); // 'John Doe'

// Using reviver function
const parsedWithDates = JSON.parse(jsonString, (key, value) => {
  if (key === 'createdAt') return new Date(value);
  return value;
});
console.log(parsedWithDates.createdAt instanceof Date); // true

// Common use case: deep cloning objects
const clonedUser = JSON.parse(JSON.stringify(user));
clonedUser.name = 'Jane Doe';
console.log(user.name); // 'John Doe' (original unchanged)
console.log(clonedUser.name); // 'Jane Doe' (clone changed)

// Limitations of JSON.stringify
const objWithUnsupportedTypes = {
  func: function() { return 'hello'; },
  undef: undefined,
  symbol: Symbol('id'),
  date: new Date()
};

console.log(JSON.stringify(objWithUnsupportedTypes));
// '{"date":"2023-05-15T12:00:00.000Z"}' (function, undefined, and symbol are omitted)
Fundamentals

What is 'use strict'?

'use strict' is a directive that enables strict mode in JavaScript. Strict mode is a restricted variant of JavaScript that intentionally has different semantics from normal code, making it more secure and less error-prone.

Key benefits of strict mode:

  • Prevents accidental global variables
  • Makes assignments that would otherwise silently fail throw errors
  • Requires explicit function calls in certain contexts
  • Disallows duplicate parameter names
  • Changes this in functions without a receiver to undefined instead of the global object
  • Disallows octal syntax
  • Makes eval and arguments less magical

Strict mode can be applied to entire scripts or individual functions.

// Script-level strict mode
'use strict';
x = 10; // ReferenceError: x is not defined (prevents accidental globals)

// Function-level strict mode
function strictFunction() {
  'use strict';
  y = 20; // ReferenceError: y is not defined
  return y;
}

// Duplicate parameter names (not allowed in strict mode)
function duplicateParams(a, a, b) { // SyntaxError in strict mode
  return a + b;
}

// Silent assignment failures become errors
undefined = 5; // TypeError in strict mode (cannot assign to undefined)
NaN = 5; // TypeError in strict mode (cannot assign to NaN)

// 'this' in functions without a receiver
function nonStrictFunction() {
  console.log(this); // Window object (in browsers)
}

function strictFunction() {
  'use strict';
  console.log(this); // undefined
}

nonStrictFunction(); // Window object
strictFunction(); // undefined

// Deleting undeletable properties
delete Object.prototype; // TypeError in strict mode

// Octal syntax is not allowed
const octal = 010; // SyntaxError in strict mode

// eval and arguments are more restricted
function strictEval() {
  'use strict';
  const eval = 5; // SyntaxError: Cannot use 'eval' as a variable name
  const arguments = []; // SyntaxError: Cannot use 'arguments' as a variable name
}
Functions & Scope

What are scopes in JavaScript?

Scope determines the accessibility (visibility) of variables, functions, and objects in different parts of the code during runtime. JavaScript has three main types of scope:

Global Scope:

  • Variables declared outside any function or block have global scope
  • Global variables can be accessed from anywhere in the code
  • In browsers, the global scope is the window object
  • Global variables should be avoided to prevent naming conflicts and unintended side effects

Function Scope:

  • Variables declared inside a function have function scope
  • They can only be accessed within that function and its nested functions
  • Only applies to variables declared with var

Block Scope:

  • Variables declared inside a block (enclosed in curly braces) have block scope
  • They can only be accessed within that block
  • Applies to variables declared with let and const
// Global scope
const globalVar = 'I am global';

function outerFunction() {
  // Function scope
  const outerVar = 'I am in outer function';
  
  function innerFunction() {
    // Function scope (nested)
    const innerVar = 'I am in inner function';
    console.log(globalVar); // 'I am global' (accessible)
    console.log(outerVar); // 'I am in outer function' (accessible)
    console.log(innerVar); // 'I am in inner function' (accessible)
  }
  
  innerFunction();
  console.log(globalVar); // 'I am global' (accessible)
  console.log(outerVar); // 'I am in outer function' (accessible)
  console.log(innerVar); // ReferenceError: innerVar is not defined (not accessible)
}

outerFunction();
console.log(globalVar); // 'I am global' (accessible)
console.log(outerVar); // ReferenceError: outerVar is not defined (not accessible)

// Block scope with let and const
if (true) {
  const blockVar = 'I am in a block';
  let anotherBlockVar = 'I am also in a block';
  console.log(blockVar); // 'I am in a block' (accessible)
}
console.log(blockVar); // ReferenceError: blockVar is not defined (not accessible)

// Function scope with var
if (true) {
  var functionScopedVar = 'I am function-scoped';
}
console.log(functionScopedVar); // 'I am function-scoped' (accessible because var is function-scoped, not block-scoped)

// Lexical scope (static scope)
function outer() {
  const x = 10;
  
  function inner() {
    // Inner function has access to outer function's variables
    console.log(x); // 10
  }
  
  return inner;
}

const closure = outer();
closure(); // 10 (inner function still has access to x even after outer has finished executing)
Functions & Scope

Function declaration vs expression vs arrow

JavaScript provides multiple ways to define functions, each with its own characteristics and use cases:

Function Declaration:

  • Defined with the function keyword followed by a name
  • Hoisted to the top of their scope, meaning they can be called before they are defined
  • Can be used as a constructor with the new keyword
  • Has its own this context determined by how it's called

Function Expression:

  • Defined as part of a larger expression, typically assigned to a variable
  • Not hoisted, meaning they cannot be called before they are defined
  • Can be named or anonymous
  • Has its own this context determined by how it's called

Arrow Function:

  • Introduced in ES6, defined with a concise syntax using =>
  • Not hoisted, meaning they cannot be called before they are defined
  • Always anonymous
  • Does not have its own this context; inherits this from the surrounding scope (lexical this)
  • Cannot be used as a constructor with the new keyword
  • Does not have an arguments object
// Function Declaration
function add(a, b) {
  return a + b;
}
console.log(add(2, 3)); // 5

// Can be called before declaration (due to hoisting)
console.log(subtract(5, 2)); // 3
function subtract(a, b) {
  return a - b;
}

// Function Expression
const multiply = function(a, b) {
  return a * b;
};
console.log(multiply(2, 3)); // 6

// Cannot be called before declaration
// console.log(divide(6, 2)); // TypeError: divide is not a function
const divide = function(a, b) {
  return a / b;
};

// Named Function Expression
const factorial = function fact(n) {
  return n <= 1 ? 1 : n * fact(n - 1);
};
console.log(factorial(5)); // 120

// Arrow Function
const square = (x) => x * x;
console.log(square(5)); // 25

// Concise syntax for single parameter
const double = x => x * 2;
console.log(double(5)); // 10

// Multi-line arrow function
const calculateArea = (radius) => {
  const pi = 3.14159;
  return pi * radius * radius;
};
console.log(calculateArea(5)); // 78.53975

// 'this' context differences
const obj = {
  value: 42,
  
  // Method using function declaration
  getValue: function() {
    return this.value;
  },
  
  // Method using arrow function
  getValueArrow: () => {
    return this.value; // 'this' does not refer to obj
  }
};

console.log(obj.getValue()); // 42
console.log(obj.getValueArrow()); // undefined (in strict mode) or window.value (in non-strict mode)

// Arrow functions are useful for callbacks
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// Arrow functions preserve 'this' from the surrounding scope
function Counter() {
  this.count = 0;
  
  setInterval(() => {
    this.count++; // 'this' refers to the Counter instance
    console.log(this.count);
  }, 1000);
}

const counter = new Counter(); // Logs 1, 2, 3, ... every second
Functions & Scope

What is an IIFE?

IIFE (Immediately Invoked Function Expression) is a JavaScript function that runs as soon as it is defined. It's a design pattern that is also known as a Self-Executing Anonymous Function.

Key characteristics of IIFEs:

  • Defined within parentheses to create a function expression
  • Immediately invoked with another set of parentheses at the end
  • Creates a private scope to avoid polluting the global namespace
  • Can accept arguments and return values
  • Commonly used for initialization tasks and module patterns

Common use cases for IIFEs:

  • Creating private variables and functions
  • Module pattern implementation
  • Initialization code that should run only once
  • Avoiding global namespace pollution
// Basic IIFE syntax
(function() {
  console.log('This function runs immediately!');
})();

// IIFE with parameters
(function(name) {
  console.log(`Hello, ${name}!`);
})('Alice');

// IIFE that returns a value
const result = (function(a, b) {
  return a + b;
})(5, 3);
console.log(result); // 8

// Using IIFE to create private scope
const counter = (function() {
  let count = 0;
  
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      count--;
      return count;
    },
    getCount: function() {
      return count;
    }
  };
})();

console.log(counter.getCount()); // 0
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(count); // ReferenceError: count is not defined (private variable)

// Module pattern with IIFE
const mathModule = (function() {
  function add(a, b) {
    return a + b;
  }
  
  function subtract(a, b) {
    return a - b;
  }
  
  function multiply(a, b) {
    return a * b;
  }
  
  // Public API
  return {
    add: add,
    subtract: subtract,
    multiply: multiply
  };
})();

console.log(mathModule.add(5, 3)); // 8
console.log(mathModule.subtract(5, 3)); // 2
console.log(mathModule.multiply(5, 3)); // 15

// Alternative IIFE syntaxes
(function() {
  console.log('Traditional IIFE');
})();

(function() {
  console.log('Another traditional IIFE');
}());

(() => {
  console.log('Arrow function IIFE');
})();

(async function() {
  console.log('Async IIFE');
  const data = await fetch('https://api.example.com/data');
  console.log('Data fetched');
})();
Functions & Scope

What is currying?

Currying is a functional programming technique that transforms a function with multiple arguments into a sequence of functions, each taking a single argument. It allows for partial application of function arguments and creates more reusable and flexible functions.

Key concepts of currying:

  • Transforms a function like f(a, b, c) into f(a)(b)(c)
  • Each function in the chain returns another function that takes the next argument
  • Enables partial application - fixing some arguments and creating a new function
  • Facilitates function composition and code reuse

Benefits of currying:

  • Creates specialized functions from more general ones
  • Improves code reusability and modularity
  • Enables function composition
  • Makes code more declarative and readable
// Simple currying example
function add(a) {
  return function(b) {
    return a + b;
  };
}

const addFive = add(5);
console.log(addFive(3)); // 8
console.log(addFive(10)); // 15

// Arrow function currying
const multiply = a => b => a * b;
const double = multiply(2);
const triple = multiply(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

// Currying a function with multiple arguments
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...nextArgs) {
        return curried.apply(this, args.concat(nextArgs));
      };
    }
  };
}

function addThree(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(addThree);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
console.log(curriedAdd(1, 2, 3)); // 6

// Practical example: creating specialized functions
const greet = (greeting) => (name) => (punctuation) => 
  `${greeting}, ${name}${punctuation}`;

const sayHello = greet('Hello');
const sayHi = greet('Hi');

const helloAlice = sayHello('Alice');
const hiBob = sayHi('Bob');

console.log(helloAlice('!')); // "Hello, Alice!"
console.log(hiBob('.')); // "Hi, Bob."

// Practical example: data processing
const filter = (predicate) => (array) => array.filter(predicate);
const map = (transform) => (array) => array.map(transform);
const reduce = (reducer, initialValue) => (array) => array.reduce(reducer, initialValue);

const isEven = n => n % 2 === 0;
const double = n => n * 2;
const sum = (acc, n) => acc + n;

const numbers = [1, 2, 3, 4, 5, 6];

const getEvenNumbers = filter(isEven);
const doubleNumbers = map(double);
const sumNumbers = reduce(sum, 0);

const result = sumNumbers(doubleNumbers(getEvenNumbers(numbers)));
console.log(result); // 24 (2+4+6 doubled = 4+8+12 = 24)

// Function composition with currying
const compose = (...fns) => (initialValue) => 
  fns.reduceRight((acc, fn) => fn(acc), initialValue);

const addOne = x => x + 1;
const multiplyByTwo = x => x * 2;
const toString = x => x.toString();

const processNumber = compose(toString, multiplyByTwo, addOne);
console.log(processNumber(5)); // "12" (5+1=6, 6*2=12, 12.toString()="12")
Scope & This

How does 'this' work?

In JavaScript, the this keyword refers to the object that is currently executing the code. The value of this is determined by how a function is called, not where it is defined. It can be a bit confusing because its value changes depending on the execution context.

Rules for determining this:

  • Default Binding: In standalone function calls, this refers to the global object (window in browsers) or undefined in strict mode.
  • Implicit Binding: When a function is called as a method of an object, this refers to the object the method is called on.
  • Explicit Binding: Using call, apply, or bind to explicitly set this.
  • New Binding: When a function is used as a constructor with the new keyword, this refers to the newly created object.
  • Arrow Function Binding: Arrow functions don't have their own this; they inherit this from the surrounding scope (lexical this).
// Default Binding
function showThis() {
  console.log(this);
}

showThis(); // Window object (in non-strict mode) or undefined (in strict mode)

// Implicit Binding
const person = {
  name: 'John',
  greet: function() {
    console.log(`Hello, I'm ${this.name}`);
  }
};

person.greet(); // "Hello, I'm John" (this refers to person)

// Borrowing method
const anotherPerson = { name: 'Jane' };
person.greet.call(anotherPerson); // "Hello, I'm Jane" (this refers to anotherPerson)

// Nested function with different this
const obj = {
  name: 'Object',
  outer: function() {
    console.log(this.name); // "Object" (this refers to obj)
    
    function inner() {
      console.log(this.name); // undefined (in strict mode) or Window.name (in non-strict mode)
    }
    
    inner();
  }
};

obj.outer();

// Explicit Binding with call, apply, and bind
function introduce(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const user = { name: 'Alice' };

introduce.call(user, 'Hi', '!'); // "Hi, I'm Alice!"
introduce.apply(user, ['Hello', '.']); // "Hello, I'm Alice."

const boundIntroduce = introduce.bind(user, 'Hey');
boundIntroduce('?'); // "Hey, I'm Alice?"

// New Binding
function Person(name) {
  this.name = name;
}

const john = new Person('John');
console.log(john.name); // "John" (this refers to the newly created object)

// Arrow Function Binding
const arrowObj = {
  name: 'Arrow Object',
  regular: function() {
    console.log(this.name); // "Arrow Object"
    
    const arrow = () => {
      console.log(this.name); // "Arrow Object" (inherits this from outer scope)
    };
    
    arrow();
  }
};

arrowObj.regular();

// Event handlers and this
const button = document.querySelector('button');
button.addEventListener('click', function() {
  console.log(this); // The button element (this refers to the element that triggered the event)
});

button.addEventListener('click', () => {
  console.log(this); // Window object (arrow functions don't get their own this)
});

// Methods in objects vs standalone functions
const calculator = {
  value: 0,
  add: function(num) {
    this.value += num;
    return this;
  },
  subtract: function(num) {
    this.value -= num;
    return this;
  },
  result: function() {
    return this.value;
  }
};

console.log(calculator.add(5).subtract(3).result()); // 2

// Extracting method loses its this binding
const add = calculator.add;
add(5); // this is not calculator anymore, so this.value is undefined
Scope & This

call vs apply vs bind

call, apply, and bind are methods available on all JavaScript functions that allow you to explicitly set the this value when calling a function. They are part of the explicit binding rules for this.

Function.prototype.call(thisArg, arg1, arg2, ...):

  • Calls a function with a given this value and arguments provided individually
  • Arguments are passed as a comma-separated list
  • Executes the function immediately

Function.prototype.apply(thisArg, [argsArray]):

  • Calls a function with a given this value and arguments provided as an array
  • Arguments are passed as an array or array-like object
  • Executes the function immediately

Function.prototype.bind(thisArg, arg1, arg2, ...):

  • Creates a new function that, when called, has its this keyword set to the provided value
  • Can pre-fill arguments (partial application)
  • Returns a new function without executing it
function introduce(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };

// Using call
introduce.call(person1, 'Hi', '!'); // "Hi, I'm Alice!"
introduce.call(person2, 'Hello', '.'); // "Hello, I'm Bob."

// Using apply
introduce.apply(person1, ['Hey', '?']); // "Hey, I'm Alice?"
introduce.apply(person2, ['Yo', '!!']); // "Yo, I'm Bob!!"

// Using bind
const introduceAlice = introduce.bind(person1, 'Hi');
const introduceBob = introduce.bind(person2, 'Hello');

introduceAlice('!'); // "Hi, I'm Alice!"
introduceBob('.'); // "Hello, I'm Bob."

// Partial application with bind
const greetAlice = introduce.bind(person1, 'Hello', '!');
greetAlice(); // "Hello, I'm Alice!"

// Practical example: borrowing methods
const numbers = [5, 6, 2, 3, 7];

// Using Math.max with apply
const max = Math.max.apply(null, numbers);
console.log(max); // 7

// Using Array.prototype.slice with call to convert array-like objects to arrays
function list() {
  return Array.prototype.slice.call(arguments);
}

console.log(list(1, 2, 3)); // [1, 2, 3]

// Using bind to preserve this in callbacks
const button = document.querySelector('button');
const obj = {
  name: 'Object',
  handleClick: function() {
    console.log(`Button clicked by ${this.name}`);
  }
};

// Without bind, this would refer to the button element
button.addEventListener('click', obj.handleClick.bind(obj));

// Using bind for function composition
const multiply = (a, b) => a * b;
const double = multiply.bind(null, 2);
console.log(double(5)); // 10

// Using bind to create methods with preset this
const module = {
  x: 42,
  getX: function() {
    return this.x;
  }
};

const unboundGetX = module.getX;
console.log(unboundGetX()); // undefined (or window.x in non-strict mode)

const boundGetX = unboundGetX.bind(module);
console.log(boundGetX()); // 42

// Using call and apply for inheritance
function Parent(name) {
  this.name = name;
}

Parent.prototype.greet = function() {
  console.log(`Hello, I'm ${this.name}`);
};

function Child(name, age) {
  Parent.call(this, name); // Call Parent constructor with Child's this
  this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.introduce = function() {
  console.log(`I'm ${this.name} and I'm ${this.age} years old`);
};

const child = new Child('Emma', 10);
child.greet(); // "Hello, I'm Emma"
child.introduce(); // "I'm Emma and I'm 10 years old"
OOP & Prototypes

Prototypal inheritance and classes

JavaScript is a prototype-based language, which means objects inherit directly from other objects. This is different from class-based languages where objects inherit from classes. ES6 introduced class syntax as syntactic sugar over JavaScript's existing prototype-based inheritance.

Prototypal Inheritance:

  • Each object has an internal property called [[Prototype]] (accessed via __proto__ or Object.getPrototypeOf())
  • When accessing a property on an object, JavaScript first checks the object itself, then its prototype, then the prototype's prototype, and so on, until it finds the property or reaches the end of the prototype chain
  • Objects can be created with a specific prototype using Object.create()

ES6 Classes:

  • Syntactic sugar over JavaScript's prototype-based inheritance
  • Provides a clearer and more familiar syntax for creating objects and implementing inheritance
  • Under the hood, still uses prototypes
// Prototypal inheritance
function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return `Hello, I'm ${this.name}`;
};

function Employee(name, title) {
  Person.call(this, name); // Call parent constructor
  this.title = title;
}

// Set up inheritance
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;

Employee.prototype.work = function() {
  return `${this.name} is working as a ${this.title}`;
};

const employee = new Employee('John', 'Developer');
console.log(employee.greet()); // "Hello, I'm John"
console.log(employee.work()); // "John is working as a Developer"

// ES6 Classes
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    return `${this.name} makes a noise`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Call parent constructor
    this.breed = breed;
  }
  
  speak() {
    return `${this.name} barks`;
  }
  
  fetch() {
    return `${this.name} is fetching`;
  }
}

const dog = new Dog('Rex', 'Golden Retriever');
console.log(dog.speak()); // "Rex barks"
console.log(dog.fetch()); // "Rex is fetching"

// Static methods
class MathUtils {
  static add(a, b) {
    return a + b;
  }
  
  static multiply(a, b) {
    return a * b;
  }
}

console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.multiply(5, 3)); // 15

// Private fields (ES2022)
class BankAccount {
  #balance = 0;
  
  constructor(initialBalance) {
    this.#balance = initialBalance;
  }
  
  deposit(amount) {
    this.#balance += amount;
    return this.#balance;
  }
  
  withdraw(amount) {
    if (amount <= this.#balance) {
      this.#balance -= amount;
      return this.#balance;
    } else {
      throw new Error('Insufficient funds');
    }
  }
  
  get balance() {
    return this.#balance;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.balance); // 150
// account.#balance = 1000; // SyntaxError: Private field '#balance' must be declared in an enclosing class

// Mixins (multiple inheritance simulation)
const canFly = {
  fly() {
    return `${this.name} is flying`;
  }
};

const canSwim = {
  swim() {
    return `${this.name} is swimming`;
  }
};

class Duck {
  constructor(name) {
    this.name = name;
  }
  
  quack() {
    return `${this.name} is quacking`;
  }
}

Object.assign(Duck.prototype, canFly, canSwim);

const duck = new Duck('Donald');
console.log(duck.quack()); // "Donald is quacking"
console.log(duck.fly()); // "Donald is flying"
console.log(duck.swim()); // "Donald is swimming"
Arrays & Objects

Array map, filter, reduce

JavaScript arrays have several powerful methods for transforming and processing data. Three of the most commonly used methods are map, filter, and reduce.

Array.prototype.map(callback):

  • Creates a new array by calling a function on every element of the original array
  • Does not modify the original array
  • The callback function receives three arguments: the current element, its index, and the array
  • Returns a new array with the results of calling the callback function

Array.prototype.filter(callback):

  • Creates a new array with all elements that pass the test implemented by the provided function
  • Does not modify the original array
  • The callback function should return true to keep the element or false to discard it
  • Returns a new array with only the elements that passed the test

Array.prototype.reduce(callback, initialValue):

  • Executes a reducer function on each element of the array, resulting in a single output value
  • Does not modify the original array
  • The callback function receives four arguments: the accumulator, the current value, the current index, and the array
  • The initialValue is the initial value of the accumulator
const numbers = [1, 2, 3, 4, 5];

// Using map to transform each element
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// Using map with objects
const users = [
  { id: 1, name: 'Alice', age: 25 },
  { id: 2, name: 'Bob', age: 30 },
  { id: 3, name: 'Charlie', age: 35 }
];

const userNames = users.map(user => user.name);
console.log(userNames); // ['Alice', 'Bob', 'Charlie']

// Using map to transform objects
const usersWithStatus = users.map(user => ({
  ...user,
  status: user.age >= 30 ? 'Senior' : 'Junior'
}));
console.log(usersWithStatus);
// [
//   { id: 1, name: 'Alice', age: 25, status: 'Junior' },
//   { id: 2, name: 'Bob', age: 30, status: 'Senior' },
//   { id: 3, name: 'Charlie', age: 35, status: 'Senior' }
// ]

// Using filter to select elements
const evenNumbers = numbers.filter(num => num % 2 === 0);
console.log(evenNumbers); // [2, 4]

// Using filter with objects
const adults = users.filter(user => user.age >= 30);
console.log(adults);
// [
//   { id: 2, name: 'Bob', age: 30 },
//   { id: 3, name: 'Charlie', age: 35 }
// ]

// Using reduce to aggregate values
const sum = numbers.reduce((acc, num) => acc + num, 0);
console.log(sum); // 15

// Using reduce to find the maximum
const max = numbers.reduce((acc, num) => Math.max(acc, num), -Infinity);
console.log(max); // 5

// Using reduce to group objects
const usersByAge = users.reduce((acc, user) => {
  const ageGroup = user.age >= 30 ? '30+' : '29-';
  if (!acc[ageGroup]) {
    acc[ageGroup] = [];
  }
  acc[ageGroup].push(user);
  return acc;
}, {});
console.log(usersByAge);
// {
//   '29-': [{ id: 1, name: 'Alice', age: 25 }],
//   '30+': [
//     { id: 2, name: 'Bob', age: 30 },
//     { id: 3, name: 'Charlie', age: 35 }
//   ]
// }

// Chaining methods together
const result = numbers
  .filter(num => num % 2 === 0)
  .map(num => num * 10)
  .reduce((acc, num) => acc + num, 0);
console.log(result); // 60 (2*10 + 4*10 = 20 + 40 = 60)

// Using reduce to implement map and filter
const customMap = (arr, fn) => arr.reduce((acc, item, index) => {
  acc.push(fn(item, index, arr));
  return acc;
}, []);

const customFilter = (arr, fn) => arr.reduce((acc, item, index) => {
  if (fn(item, index, arr)) {
    acc.push(item);
  }
  return acc;
}, []);

console.log(customMap(numbers, num => num * 2)); // [2, 4, 6, 8, 10]
console.log(customFilter(numbers, num => num % 2 === 0)); // [2, 4]
Arrays & Objects

Shallow vs deep copy

In JavaScript, objects and arrays are reference types, which means when you assign them to a variable or pass them as arguments, you're passing a reference to the original object, not a copy. To create a copy, you need to understand the difference between shallow and deep copying.

Shallow Copy:

  • Copies only the top-level properties of an object
  • Nested objects or arrays are still referenced, not copied
  • Changes to nested properties in the copy will affect the original
  • Methods: spread operator (...), Object.assign(), Array.slice()

Deep Copy:

  • Copies all levels of nested objects and arrays
  • Changes to any property in the copy will not affect the original
  • Methods: JSON.parse(JSON.stringify(obj)), structuredClone(), custom recursive functions
// Shallow copy examples
const original = {
  name: 'John',
  age: 30,
  address: {
    street: '123 Main St',
    city: 'New York'
  }
};

// Using spread operator
const shallowCopy1 = { ...original };

// Using Object.assign
const shallowCopy2 = Object.assign({}, original);

// Modifying top-level property
shallowCopy1.name = 'Jane';
console.log(original.name); // 'John' (original unchanged)

// Modifying nested property
shallowCopy1.address.city = 'Boston';
console.log(original.address.city); // 'Boston' (original changed)

// Array shallow copy
const originalArray = [1, 2, [3, 4]];
const shallowArrayCopy = [...originalArray];

shallowArrayCopy[0] = 99;
console.log(originalArray[0]); // 1 (original unchanged)

shallowArrayCopy[2][0] = 99;
console.log(originalArray[2][0]); // 99 (original changed)

// Deep copy examples
// Using JSON.parse(JSON.stringify())
const deepCopy1 = JSON.parse(JSON.stringify(original));

// Using structuredClone (modern browsers)
const deepCopy2 = structuredClone(original);

// Custom recursive deep copy function
function deepCopy(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  const copy = Array.isArray(obj) ? [] : {};
  
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      copy[key] = deepCopy(obj[key]);
    }
  }
  
  return copy;
}

const deepCopy3 = deepCopy(original);

// Modifying nested property in deep copy
deepCopy1.address.city = 'Chicago';
console.log(original.address.city); // 'Boston' (original unchanged)

// Limitations of JSON.parse(JSON.stringify())
const objWithUnsupportedTypes = {
  date: new Date(),
  regex: /test/,
  func: function() { return 'hello'; },
  undef: undefined,
  symbol: Symbol('id')
};

const jsonCopy = JSON.parse(JSON.stringify(objWithUnsupportedTypes));
console.log(jsonCopy);
// {
//   "date": "2023-05-15T12:00:00.000Z" (converted to string)
//   // regex, func, undef, and symbol are omitted
// }

// Deep copy with circular references
const obj = { name: 'Object' };
obj.self = obj; // Circular reference

try {
  const circularCopy = JSON.parse(JSON.stringify(obj));
} catch (error) {
  console.error(error); // TypeError: Converting circular structure to JSON
}

// Using a library like lodash for deep copy
// import { cloneDeep } from 'lodash';
// const lodashDeepCopy = cloneDeep(original);
ES6+

Spread and Rest

Spread and Rest operators both use the same syntax (...) but have opposite functions. The Spread operator expands iterables into individual elements, while the Rest operator collects multiple elements into a single array.

Spread Operator (...):

  • Expands an iterable (like an array or string) into individual elements
  • Used in function calls, array literals, and object literals
  • Creates a shallow copy of arrays and objects

Rest Operator (...):

  • Collects multiple elements into a single array
  • Used in function parameter lists and destructuring assignments
  • Must be the last parameter in a function definition
// Spread operator with arrays
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2];
console.log(combined); // [1, 2, 3, 4, 5, 6]

// Spread operator with function calls
const numbers = [1, 2, 3];
console.log(Math.max(...numbers)); // 3

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

// Overriding properties with spread
const original = { a: 1, b: 2, c: 3 };
const updated = { ...original, b: 20, d: 4 };
console.log(updated); // { a: 1, b: 20, c: 3, d: 4 }

// Spread operator with strings
const str = 'hello';
const chars = [...str];
console.log(chars); // ['h', 'e', 'l', 'l', 'o']

// Rest operator in function parameters
function sum(...numbers) {
  return numbers.reduce((total, num) => total + num, 0);
}

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

// Rest operator with other parameters
function greet(greeting, ...names) {
  names.forEach(name => {
    console.log(`${greeting}, ${name}!`);
  });
}

greet('Hello', 'Alice', 'Bob', 'Charlie');
// "Hello, Alice!"
// "Hello, Bob!"
// "Hello, Charlie!"

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

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

// Practical example: cloning with spread
const originalArray = [1, 2, 3];
const clonedArray = [...originalArray];

const originalObject = { x: 1, y: 2 };
const clonedObject = { ...originalObject };

// Practical example: merging objects
const defaults = { theme: 'light', fontSize: 16 };
const userSettings = { fontSize: 20, color: 'blue' };
const finalSettings = { ...defaults, ...userSettings };
console.log(finalSettings); // { theme: 'light', fontSize: 20, color: 'blue' }

// Practical example: removing elements from an array
const numbers = [1, 2, 3, 4, 5];
const withoutThree = numbers.filter(n => n !== 3);
console.log(withoutThree); // [1, 2, 4, 5]

// Using spread to remove elements by index
const removeIndex = (arr, index) => [
  ...arr.slice(0, index),
  ...arr.slice(index + 1)
];

const withoutIndex2 = removeIndex(numbers, 2);
console.log(withoutIndex2); // [1, 2, 4, 5]
ES6+

Destructuring

Destructuring is a JavaScript expression that makes it possible to unpack values from arrays or properties from objects into distinct variables. It provides a more concise way to extract data from arrays and objects.

Array Destructuring:

  • Extracts values from arrays based on their position
  • Can skip values with commas
  • Can use default values for undefined elements
  • Can use the rest operator to collect remaining elements

Object Destructuring:

  • Extracts properties from objects based on their keys
  • Can rename properties during extraction
  • Can use default values for undefined properties
  • Can extract nested properties
// Array destructuring
const colors = ['red', 'green', 'blue', 'yellow'];

const [first, second, third] = colors;
console.log(first); // 'red'
console.log(second); // 'green'
console.log(third); // 'blue'

// Skipping elements
const [primary, , tertiary] = colors;
console.log(primary); // 'red'
console.log(tertiary); // 'blue'

// Default values
const [a, b, c, d, e = 'purple'] = colors;
console.log(e); // 'purple' (colors[4] is undefined, so default is used)

// Rest operator with array destructuring
const [head, ...tail] = colors;
console.log(head); // 'red'
console.log(tail); // ['green', 'blue', 'yellow']

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

// Object destructuring
const person = {
  name: 'John',
  age: 30,
  email: 'john@example.com'
};

const { name, age, email } = person;
console.log(name); // 'John'
console.log(age); // 30
console.log(email); // 'john@example.com'

// Renaming properties
const { name: fullName, age: years } = person;
console.log(fullName); // 'John'
console.log(years); // 30

// Default values
const { name, country = 'USA' } = person;
console.log(country); // 'USA' (person.country is undefined, so default is used)

// Nested destructuring
const user = {
  id: 1,
  profile: {
    firstName: 'Jane',
    lastName: 'Doe',
    address: {
      street: '123 Main St',
      city: 'New York'
    }
  }
};

const { profile: { firstName, lastName, address: { city } } } = user;
console.log(firstName); // 'Jane'
console.log(lastName); // 'Doe'
console.log(city); // 'New York'

// Destructuring in function parameters
function greet({ name, age }) {
  console.log(`Hello, ${name}! You are ${age} years old.`);
}

greet(person); // "Hello, John! You are 30 years old."

// Destructuring with default values in function parameters
function createUser({ name = 'Guest', age = 0, email = '' } = {}) {
  return { name, age, email };
}

console.log(createUser()); // { name: 'Guest', age: 0, email: '' }
console.log(createUser({ name: 'Alice' })); // { name: 'Alice', age: 0, email: '' }

// Destructuring return values
function getCoordinates() {
  return { x: 10, y: 20 };
}

const { x, y } = getCoordinates();
console.log(x); // 10
console.log(y); // 20

// Practical example: extracting data from API response
const apiResponse = {
  data: {
    users: [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ],
    pagination: {
      page: 1,
      totalPages: 5
    }
  }
};

const { data: { users, pagination: { page, totalPages } } } = apiResponse;
console.log(users); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
console.log(page); // 1
console.log(totalPages); // 5
ES6+

Optional chaining (?.) and nullish (??)

Optional chaining (?.) and nullish coalescing (??) are modern JavaScript features that simplify working with potentially null or undefined values.

Optional Chaining (?.):

  • Allows reading the value of a property located deep within a chain of connected objects
  • Stops the evaluation and returns undefined if a reference in the chain is null or undefined
  • Can be used with property access, function calls, and element access

Nullish Coalescing (??):

  • Returns the right-hand operand when the left-hand operand is null or undefined
  • Unlike the logical OR operator (||), it doesn't treat other falsy values (0, '', false, NaN) as nullish
  • Useful for providing default values only when a value is actually null or undefined
// Optional chaining with objects
const user = {
  id: 1,
  name: 'John',
  // address is not defined
};

// Without optional chaining
const city = user && user.address && user.address.city;
console.log(city); // undefined

// With optional chaining
const cityWithOptional = user?.address?.city;
console.log(cityWithOptional); // undefined (no error)

// Optional chaining with function calls
const obj = {
  id: 1,
  // method is not defined
};

// Without optional chaining
const result = obj.method && obj.method();
console.log(result); // undefined

// With optional chaining
const resultWithOptional = obj.method?.();
console.log(resultWithOptional); // undefined (no error)

// Optional chaining with array access
const arr = [
  { id: 1, name: 'Alice' },
  // second element is undefined
  { id: 3, name: 'Charlie' }
];

// Without optional chaining
const secondName = arr[1] && arr[1].name;
console.log(secondName); // undefined

// With optional chaining
const secondNameWithOptional = arr[1]?.name;
console.log(secondNameWithOptional); // undefined (no error)

// Nullish coalescing
const userInput = '';
const defaultValue = userInput || 'Default value';
console.log(defaultValue); // 'Default value' (empty string is falsy)

const nullishDefault = userInput ?? 'Default value';
console.log(nullishDefault); // '' (empty string is not nullish)

// Combining optional chaining and nullish coalescing
const user = {
  id: 1,
  profile: {
    // name is not defined
  }
};

const name = user.profile?.name ?? 'Anonymous';
console.log(name); // 'Anonymous'

// More examples with nullish coalescing
const quantity = 0;
const message = quantity ?? 'No quantity';
console.log(message); // 0 (0 is not nullish)

const emptyString = '';
const message2 = emptyString ?? 'Empty';
console.log(message2); // '' (empty string is not nullish)

const nullValue = null;
const message3 = nullValue ?? 'Null value';
console.log(message3); // 'Null value' (null is nullish)

// Practical example: API response handling
const apiResponse = {
  data: {
    user: {
      id: 1,
      name: 'John',
      // email is not defined
    }
  }
};

const email = apiResponse?.data?.user?.email ?? 'No email provided';
console.log(email); // 'No email provided'

// Practical example: configuration with defaults
const config = {
  apiUrl: 'https://api.example.com',
  // timeout is not defined
  retries: 0
};

const finalConfig = {
  apiUrl: config.apiUrl ?? 'https://default.api.com',
  timeout: config.timeout ?? 5000,
  retries: config.retries ?? 3
};

console.log(finalConfig);
// { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 }

// Practical example: nested property access
const deepObject = {
  level1: {
    level2: {
      level3: {
        value: 'Found it!'
      }
    }
  }
};

const value = deepObject?.level1?.level2?.level3?.value ?? 'Not found';
console.log(value); // 'Found it!'

const missingValue = deepObject?.level1?.missing?.value ?? 'Not found';
console.log(missingValue); // 'Not found'
ES6+

Template literals

Template literals are string literals allowing for embedded expressions. They are enclosed by backticks (`) instead of single or double quotes. Template literals can contain placeholders, which are indicated by the dollar sign and curly braces (${expression}).

Key features of template literals:

  • String interpolation with embedded expressions
  • Multi-line strings without needing escape characters
  • Tagged templates for custom string processing
// Basic string interpolation
const name = 'John';
const age = 30;
const message = `Hello, my name is ${name} and I'm ${age} years old.`;
console.log(message); // "Hello, my name is John and I'm 30 years old."

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

// Function calls in template literals
function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

const firstName = 'john';
const lastName = 'doe';
const fullName = `My name is ${capitalize(firstName)} ${capitalize(lastName)}`;
console.log(fullName); // "My name is John Doe"

// Multi-line strings
const multiLine = `
This is a multi-line string.
It can span multiple lines
without needing escape characters.
`;
console.log(multiLine);
// "This is a multi-line string.
// It can span multiple lines
// without needing escape characters."

// Comparison with traditional strings
const traditional = 'This is a multi-line string.\n' +
                   'It requires newline characters\n' +
                   'to span multiple lines.';
console.log(traditional);

// Template literals with HTML
const user = { name: 'Alice', age: 25 };
const html = `
  <div class="user">
    <h2>${user.name}</h2>
    <p>Age: ${user.age}</p>
  </div>
`;
console.log(html);

// Tagged templates
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    const value = values[i] ? `<mark>${values[i]}</mark>` : '';
    return result + str + value;
  }, '');
}

const highlighted = highlight`The sum of ${a} and ${b} is ${a + b}`;
console.log(highlighted); // "The sum of <mark>10</mark> and <mark>20</mark> is <mark>30</mark>"

// Practical example: SQL query builder
function query(table, conditions) {
  const whereClause = Object.keys(conditions)
    .map(key => `${key} = '${conditions[key]}'`)
    .join(' AND ');
    
  return `SELECT * FROM ${table} WHERE ${whereClause}`;
}

const sql = query('users', { id: 1, status: 'active' });
console.log(sql); // "SELECT * FROM users WHERE id = '1' AND status = 'active'"

// Practical example: URL construction
function buildUrl(base, path, params) {
  const queryString = Object.keys(params)
    .map(key => `${key}=${encodeURIComponent(params[key])}`)
    .join('&');
    
  return `${base}/${path}?${queryString}`;
}

const url = buildUrl('https://api.example.com', 'users', { page: 1, limit: 10 });
console.log(url); // "https://api.example.com/users?page=1&limit=10"

// Practical example: styled components (simplified)
function styled(strings, ...values) {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] || '');
  }, '');
}

const color = 'blue';
const size = '16px';
const style = styled`
  color: ${color};
  font-size: ${size};
`;
console.log(style); // "color: blue; font-size: 16px;"
ES6+

Set vs Map

Set and Map are two new data structures introduced in ES6 that provide more efficient ways to store and manipulate data compared to traditional objects and arrays.

Set:

  • A collection of unique values
  • Values can be of any type
  • Values are stored in insertion order
  • Useful for removing duplicates from arrays
  • Provides methods like add, delete, has, and clear

Map:

  • A collection of key-value pairs
  • Keys can be of any type (including objects)
  • Entries are stored in insertion order
  • More efficient than objects for frequent additions and removals
  • Provides methods like set, get, has, delete, and clear
// Set examples
const set = new Set([1, 2, 3, 3, 4]);
console.log(set); // Set { 1, 2, 3, 4 } (duplicates are removed)

// Adding elements
set.add(5);
set.add(1); // Already exists, won't be added again
console.log(set); // Set { 1, 2, 3, 4, 5 }

// Checking for elements
console.log(set.has(3)); // true
console.log(set.has(6)); // false

// Deleting elements
set.delete(2);
console.log(set.has(2)); // false

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

// Clearing all elements
set.clear();
console.log(set.size); // 0

// Removing duplicates from an array
const numbers = [1, 2, 2, 3, 4, 4, 5];
const uniqueNumbers = [...new Set(numbers)];
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]

// Iterating over a Set
const colors = new Set(['red', 'green', 'blue']);

// Using forEach
colors.forEach(color => {
  console.log(color);
});

// Using for...of
for (const color of colors) {
  console.log(color);
}

// Map examples
const map = new Map([
  ['name', 'John'],
  ['age', 30]
]);
console.log(map); // Map { 'name' => 'John', 'age' => 30 }

// Setting values
map.set('email', 'john@example.com');
map.set('name', 'Jane'); // Updates existing key
console.log(map.get('name')); // 'Jane'

// Using objects as keys
const obj1 = { id: 1 };
const obj2 = { id: 2 };
const objectMap = new Map();

objectMap.set(obj1, 'Value for obj1');
objectMap.set(obj2, 'Value for obj2');

console.log(objectMap.get(obj1)); // 'Value for obj1'
console.log(objectMap.get({ id: 1 })); // undefined (different object reference)

// Checking for keys
console.log(map.has('age')); // true
console.log(map.has('address')); // false

// Deleting entries
map.delete('age');
console.log(map.has('age')); // false

// Getting size
console.log(map.size); // 2

// Iterating over a Map
const userMap = new Map([
  ['name', 'Alice'],
  ['age', 25],
  ['email', 'alice@example.com']
]);

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

// Using for...of
for (const [key, value] of userMap) {
  console.log(`${key}: ${value}`);
}

// Getting keys, values, and entries
console.log([...userMap.keys()]); // ['name', 'age', 'email']
console.log([...userMap.values()]); // ['Alice', 25, 'alice@example.com']
console.log([...userMap.entries()]); // [['name', 'Alice'], ['age', 25], ['email', 'alice@example.com']]

// Practical example: counting occurrences
const text = 'hello world hello';
const words = text.split(' ');
const wordCount = new Map();

words.forEach(word => {
  const count = wordCount.get(word) || 0;
  wordCount.set(word, count + 1);
});

console.log(wordCount); // Map { 'hello' => 2, 'world' => 1 }

// Practical example: caching function results
const cache = new Map();

function fibonacci(n) {
  if (n <= 1) return n;
  
  if (cache.has(n)) {
    return cache.get(n);
  }
  
  const result = fibonacci(n - 1) + fibonacci(n - 2);
  cache.set(n, result);
  return result;
}

console.log(fibonacci(10)); // 55
console.log(cache.size); // 10 (results for 0-9 are cached)
ES6+

WeakMap vs WeakSet

WeakMap and WeakSet are specialized versions of Map and Set that hold "weak" references to their keys or values. This means that if there are no other references to an object stored in a WeakMap or WeakSet, it can be garbage collected.

WeakMap:

  • A collection of key-value pairs where the keys are objects (not primitive values)
  • Keys are weakly referenced, allowing garbage collection when no other references exist
  • Not enumerable - cannot be iterated over
  • No size property
  • Useful for storing private data or metadata about objects

WeakSet:

  • A collection of objects where each object can only appear once in the set
  • Objects are weakly referenced, allowing garbage collection when no other references exist
  • Not enumerable - cannot be iterated over
  • No size property
  • Useful for tracking object relationships without preventing garbage collection
// WeakMap examples
const weakMap = new WeakMap();

let obj1 = { id: 1 };
let obj2 = { id: 2 };

weakMap.set(obj1, 'Data for obj1');
weakMap.set(obj2, 'Data for obj2');

console.log(weakMap.get(obj1)); // 'Data for obj1'
console.log(weakMap.has(obj1)); // true

weakMap.delete(obj1);
console.log(weakMap.has(obj1)); // false

// Keys must be objects
try {
  weakMap.set('key', 'value'); // TypeError: Invalid value used as weak map key
} catch (error) {
  console.error(error);
}

// Practical example: storing private data
const privateData = new WeakMap();

class Person {
  constructor(name, age) {
    privateData.set(this, { name, age });
  }
  
  getName() {
    return privateData.get(this).name;
  }
  
  getAge() {
    return privateData.get(this).age;
  }
}

const person = new Person('John', 30);
console.log(person.getName()); // 'John'
console.log(person.getAge()); // 30

// Private data is not accessible from outside
console.log(privateData.get(person)); // { name: 'John', age: 30 }

// When person is no longer referenced, the private data can be garbage collected
person = null; // Both person and its private data can be garbage collected

// WeakSet examples
const weakSet = new WeakSet();

let obj3 = { id: 3 };
let obj4 = { id: 4 };

weakSet.add(obj3);
weakSet.add(obj4);

console.log(weakSet.has(obj3)); // true
console.log(weakSet.has(obj4)); // true

weakSet.delete(obj3);
console.log(weakSet.has(obj3)); // false

// Objects can only be added once
weakSet.add(obj4);
weakSet.add(obj4); // Still only one reference to obj4

// Practical example: tracking active objects
const activeObjects = new WeakSet();

class Resource {
  constructor(id) {
    this.id = id;
    activeObjects.add(this);
  }
  
  activate() {
    activeObjects.add(this);
    console.log(`Resource ${this.id} activated`);
  }
  
  deactivate() {
    activeObjects.delete(this);
    console.log(`Resource ${this.id} deactivated`);
  }
  
  isActive() {
    return activeObjects.has(this);
  }
}

const resource1 = new Resource(1);
const resource2 = new Resource(2);

console.log(resource1.isActive()); // true
console.log(resource2.isActive()); // true

resource1.deactivate();
console.log(resource1.isActive()); // false

// When resource2 is no longer referenced, it can be garbage collected
resource2 = null; // resource2 can be garbage collected even though it's in the WeakSet

// Practical example: memoization with WeakMap
const memoize = (fn) => {
  const cache = new WeakMap();
  
  return (obj) => {
    if (cache.has(obj)) {
      return cache.get(obj);
    }
    
    const result = fn(obj);
    cache.set(obj, result);
    return result;
  };
};

const processObject = memoize(obj => {
  console.log('Processing object...');
  return { ...obj, processed: true };
});

const testObj = { id: 1, data: 'test' };
console.log(processObject(testObj)); // Processes and returns processed object
console.log(processObject(testObj)); // Returns cached result without processing

// When testObj is no longer referenced, the cached result can be garbage collected
testObj = null; // Both testObj and its cached result can be garbage collected
ES6+

Generators and Iterators

Generators and Iterators are powerful features in JavaScript that provide a way to control the flow of execution and work with sequences of data.

Iterators:

  • Objects that provide a standardized way to access a sequence of values
  • Implement the Iterator protocol with a next() method
  • The next() method returns an object with value and done properties
  • Built-in iterables include arrays, strings, maps, sets, and more

Generators:

  • Special functions that can be paused and resumed
  • Defined with function* syntax
  • Use the yield keyword to pause execution and return a value
  • Automatically create iterators when called
  • Useful for lazy evaluation, async operations, and custom iteration
// Iterator example
const array = [1, 2, 3];
const iterator = array[Symbol.iterator]();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

// Custom iterator
const rangeIterator = {
  from: 1,
  to: 5,
  
  [Symbol.iterator]() {
    return {
      current: this.from,
      last: this.to,
      
      next() {
        if (this.current <= this.last) {
          return { value: this.current++, done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const num of rangeIterator) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Generator function
function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = numberGenerator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }

// Using for...of with a generator
for (const num of numberGenerator()) {
  console.log(num); // 1, 2, 3
}

// Generator with parameters
function* range(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

for (const num of range(1, 5)) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Generator with infinite sequence
function* fibonacci() {
  let a = 0, b = 1;
  
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3

// Using yield* to delegate to another generator
function* combinedGenerator() {
  yield* range(1, 3);
  yield* ['a', 'b', 'c'];
  yield* fibonacci();
}

const combined = combinedGenerator();
console.log(combined.next().value); // 1
console.log(combined.next().value); // 2
console.log(combined.next().value); // 3
console.log(combined.next().value); // 'a'
console.log(combined.next().value); // 'b'
console.log(combined.next().value); // 'c'
console.log(combined.next().value); // 0 (first fibonacci number)

// Passing values back to the generator
function* twoWayGenerator() {
  const received = yield 'First value';
  console.log('Received:', received);
  
  const received2 = yield 'Second value';
  console.log('Received2:', received2);
  
  return 'Final result';
}

const twoWay = twoWayGenerator();
console.log(twoWay.next()); // { value: 'First value', done: false }
console.log(twoWay.next('Hello')); // Logs: 'Received: Hello', returns { value: 'Second value', done: false }
console.log(twoWay.next('World')); // Logs: 'Received2: World', returns { value: 'Final result', done: true }

// Practical example: async generator for paginated API
async function* fetchPages(url) {
  let nextPage = url;
  
  while (nextPage) {
    const response = await fetch(nextPage);
    const data = await response.json();
    
    yield data.results;
    
    nextPage = data.next;
  }
}

// Usage:
// for await (const page of fetchPages('https://api.example.com/data')) {
//   console.log(page);
// }

// Practical example: implementing a custom iterable
class TaskList {
  constructor() {
    this.tasks = [];
  }
  
  addTask(task) {
    this.tasks.push(task);
  }
  
  [Symbol.iterator]() {
    let index = 0;
    const tasks = this.tasks;
    
    return {
      next() {
        if (index < tasks.length) {
          return { value: tasks[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
}

const taskList = new TaskList();
taskList.addTask('Task 1');
taskList.addTask('Task 2');
taskList.addTask('Task 3');

for (const task of taskList) {
  console.log(task); // 'Task 1', 'Task 2', 'Task 3'
}
Asynchronous JavaScript

What is async/await?

async/await is a modern syntax for handling asynchronous operations in JavaScript. It is built on top of Promises and provides a more concise and readable way to write asynchronous code that looks and behaves like synchronous code.

async function:

  • Declares a function that returns a Promise
  • Enables the use of await inside the function
  • Automatically wraps the return value in Promise.resolve()

await expression:

  • Pauses the execution of an async function until a Promise is settled
  • Can only be used inside an async function
  • Returns the resolved value of the Promise
  • Throws an error if the Promise is rejected
// Basic async/await example
async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return data;
}

fetchData().then(data => console.log(data));

// Using try/catch for error handling
async function fetchWithErrorHandling() {
  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);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}

fetchWithErrorHandling();

// Sequential async operations
async function fetchUserData(userId) {
  try {
    const userResponse = await fetch(`https://api.example.com/users/${userId}`);
    const user = await userResponse.json();
    
    const postsResponse = await fetch(`https://api.example.com/users/${userId}/posts`);
    const posts = await postsResponse.json();
    
    return { user, posts };
  } catch (error) {
    console.error('Error fetching user data:', error);
    throw error;
  }
}

fetchUserData(1).then(data => {
  console.log('User:', data.user);
  console.log('Posts:', data.posts);
});

// Parallel async operations
async function fetchMultipleData() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('https://api.example.com/users').then(res => res.json()),
      fetch('https://api.example.com/posts').then(res => res.json()),
      fetch('https://api.example.com/comments').then(res => res.json())
    ]);
    
    return { users, posts, comments };
  } catch (error) {
    console.error('Error fetching data:', error);
    throw error;
  }
}

fetchMultipleData().then(data => {
  console.log('Users:', data.users);
  console.log('Posts:', data.posts);
  console.log('Comments:', data.comments);
});

// Using async/await with loops
async function processItems(items) {
  const results = [];
  
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }
  
  return results;
}

async function processItem(item) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(`Processed: ${item}`);
    }, 1000);
  });
}

processItems(['Item 1', 'Item 2', 'Item 3']).then(results => {
  console.log(results);
});

// Top-level await (ES2022)
// In an ES module, you can use await at the top level
// const data = await fetch('https://api.example.com/data');
// console.log(data);

// Async IIFE (Immediately Invoked Function Expression)
(async () => {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
})();

// Converting Promise chains to async/await
// Promise chain:
function fetchUserPromiseChain() {
  return fetch('https://api.example.com/users/1')
    .then(response => response.json())
    .then(user => {
      return fetch(`https://api.example.com/posts?userId=${user.id}`);
    })
    .then(response => response.json())
    .then(posts => {
      console.log('Posts:', posts);
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

// Equivalent async/await:
async function fetchUserAsync() {
  try {
    const userResponse = await fetch('https://api.example.com/users/1');
    const user = await userResponse.json();
    
    const postsResponse = await fetch(`https://api.example.com/posts?userId=${user.id}`);
    const posts = await postsResponse.json();
    
    console.log('Posts:', posts);
  } catch (error) {
    console.error('Error:', error);
  }
}
Asynchronous JavaScript

What are Promises?

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. A Promise can be in one of three states:

  • Pending: The initial state; neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.

Once a Promise is fulfilled or rejected, it is considered settled and its state cannot change.

Promise methods:

  • .then(onFulfilled, onRejected) - Handles fulfillment and rejection
  • .catch(onRejected) - Handles only rejection
  • .finally(onFinally) - Runs regardless of fulfillment or rejection
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = Math.random() > 0.5;
    
    if (success) {
      resolve('Operation successful!');
    } else {
      reject(new Error('Operation failed!'));
    }
  }, 1000);
});

// Consuming a Promise
myPromise
  .then(result => {
    console.log(result); // 'Operation successful!'
  })
  .catch(error => {
    console.error(error); // 'Error: Operation failed!'
  })
  .finally(() => {
    console.log('Operation completed');
  });

// Creating a resolved Promise
const resolvedPromise = Promise.resolve('Already resolved');
resolvedPromise.then(value => console.log(value)); // 'Already resolved'

// Creating a rejected Promise
const rejectedPromise = Promise.reject(new Error('Already rejected'));
rejectedPromise.catch(error => console.error(error)); // 'Error: Already rejected'

// Chaining Promises
function fetchUser(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: userId, name: `User ${userId}` });
    }, 1000);
  });
}

function fetchUserPosts(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([
        { id: 1, userId, title: 'Post 1' },
        { id: 2, userId, title: 'Post 2' }
      ]);
    }, 1000);
  });
}

fetchUser(1)
  .then(user => {
    console.log('User:', user);
    return fetchUserPosts(user.id);
  })
  .then(posts => {
    console.log('Posts:', posts);
  })
  .catch(error => {
    console.error('Error:', error);
  });

// Promise.all - waits for all Promises to fulfill
const promise1 = Promise.resolve(3);
const promise2 = new Promise(resolve => setTimeout(() => resolve('foo'), 1000));
const promise3 = Promise.resolve(42);

Promise.all([promise1, promise2, promise3])
  .then(values => {
    console.log(values); // [3, 'foo', 42]
  })
  .catch(error => {
    console.error('One of the promises rejected:', error);
  });

// Promise.race - settles as soon as one Promise settles
const promise4 = new Promise(resolve => setTimeout(() => resolve('one'), 500));
const promise5 = new Promise(resolve => setTimeout(() => resolve('two'), 100));

Promise.race([promise4, promise5])
  .then(value => {
    console.log(value); // 'one' (settles first)
  });

// Promise.allSettled - waits for all Promises to settle (fulfilled or rejected)
const promise6 = Promise.resolve(33);
const promise7 = new Promise(resolve => setTimeout(() => resolve(66), 0));
const promise8 = new Promise((resolve, reject) => setTimeout(() => reject(new Error('An error')), 0));

Promise.allSettled([promise6, promise7, promise8])
  .then(results => {
    console.log(results);
    // [
    //   { status: 'fulfilled', value: 33 },
    //   { status: 'fulfilled', value: 66 },
    //   { status: 'rejected', reason: Error: An error }
    // ]
  });

// Converting callback-based functions to Promises
function setTimeoutPromise(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

setTimeoutPromise(1000).then(() => {
  console.log('1 second has passed');
});

// Error handling in Promises
function mightFail() {
  return new Promise((resolve, reject) => {
    const success = Math.random() > 0.5;
    
    if (success) {
      resolve('Success!');
    } else {
      reject(new Error('Failure!'));
    }
  });
}

mightFail()
  .then(result => {
    console.log(result);
    return 'Next step';
  })
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error('Caught error:', error.message);
    return 'Error recovery';
  })
  .then(result => {
    console.log(result);
  });

// Practical example: fetching data with fetch API
function fetchData(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      return response.json();
    });
}

fetchData('https://jsonplaceholder.typicode.com/posts/1')
  .then(data => {
    console.log('Post:', data);
  })
  .catch(error => {
    console.error('Error fetching post:', error);
  });
Asynchronous JavaScript

What is the Event Loop?

The event loop is a crucial concept for understanding asynchronous JavaScript. It's a mechanism that allows JavaScript to perform non-blocking I/O operations despite being single-threaded. The event loop continuously checks the call stack and the task queue, moving tasks from the queue to the stack when the stack is empty.

Key components of the JavaScript runtime:

  • Call Stack: Keeps track of function calls (LIFO structure)
  • Heap: Stores objects and variables
  • Web APIs: Browser-provided APIs for asynchronous operations
  • Task Queue (Callback Queue): Holds callbacks ready to be executed
  • Microtask Queue: Holds higher-priority tasks (Promise callbacks, mutation observers)

How the event loop works:

  1. Execute all synchronous code from the call stack
  2. When the call stack is empty, check the microtask queue
  3. Execute all tasks in the microtask queue
  4. When the microtask queue is empty, check the task queue
  5. Execute one task from the task queue
  6. Repeat the process
console.log('Start');

setTimeout(() => {
  console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise callback');
});

console.log('End');

// Output order:
// 'Start'
// 'End'
// 'Promise callback' (microtask, higher priority)
// 'Timeout callback' (macrotask, lower priority)

// More complex example
console.log('Script start');

setTimeout(() => {
  console.log('setTimeout 1');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1');
}).then(() => {
  console.log('Promise 2');
});

setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

console.log('Script end');

// Output order:
// 'Script start'
// 'Script end'
// 'Promise 1'
// 'Promise 2'
// 'setTimeout 1'
// 'setTimeout 2'

// Example with async/await
async function asyncFunction() {
  console.log('Async function start');
  
  await Promise.resolve();
  
  console.log('After await');
}

console.log('Script start');

asyncFunction();

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

console.log('Script end');

// Output order:
// 'Script start'
// 'Async function start'
// 'Script end'
// 'Promise'
// 'After await'
// 'Timeout'

// Example demonstrating blocking vs non-blocking
function blockingOperation() {
  const start = Date.now();
  while (Date.now() - start < 1000) {
    // Block for 1 second
  }
  console.log('Blocking operation completed');
}

console.log('Start');

setTimeout(() => {
  console.log('Timeout callback');
}, 0);

blockingOperation();

console.log('End');

// Output order:
// 'Start'
// 'Blocking operation completed' (blocks for 1 second)
// 'End'
// 'Timeout callback' (only runs after the blocking operation completes)

// Practical example: understanding event loop with fetch
console.log('Fetching data...');

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json())
  .then(data => {
    console.log('Data received:', data.title);
  });

console.log('Fetch request sent');

setTimeout(() => {
  console.log('Timeout after fetch');
}, 0);

// Output order:
// 'Fetching data...'
// 'Fetch request sent'
// 'Timeout after fetch'
// 'Data received: [post title]'
Asynchronous JavaScript

Promise.all vs race vs allSettled

JavaScript provides several Promise combinators that allow you to work with multiple Promises simultaneously. Each has different behavior and use cases.

Promise.all(iterable):

  • Takes an iterable of Promises and returns a new Promise
  • Fulfills when all Promises in the iterable fulfill
  • Rejects immediately when any Promise in the iterable rejects
  • Useful when you need all operations to succeed

Promise.race(iterable):

  • Takes an iterable of Promises and returns a new Promise
  • Fulfills or rejects as soon as the first Promise in the iterable settles
  • Useful when you want the result of the fastest operation

Promise.allSettled(iterable):

  • Takes an iterable of Promises and returns a new Promise
  • Fulfills when all Promises in the iterable settle (fulfill or reject)
  • Returns an array of objects describing the outcome of each Promise
  • Useful when you want to wait for all operations to complete regardless of success or failure
// Promise.all example
const promise1 = Promise.resolve(3);
const promise2 = new Promise(resolve => setTimeout(() => resolve('foo'), 1000));
const promise3 = Promise.resolve(42);

Promise.all([promise1, promise2, promise3])
  .then(values => {
    console.log('All promises fulfilled:', values); // [3, 'foo', 42]
  })
  .catch(error => {
    console.error('One promise rejected:', error);
  });

// Promise.all with rejection
const promise4 = Promise.resolve(3);
const promise5 = Promise.reject(new Error('Promise failed'));
const promise6 = Promise.resolve(42);

Promise.all([promise4, promise5, promise6])
  .then(values => {
    console.log('All promises fulfilled:', values);
  })
  .catch(error => {
    console.error('One promise rejected:', error.message); // 'Promise failed'
  });

// Promise.race example
const promise7 = new Promise(resolve => setTimeout(() => resolve('one'), 500));
const promise8 = new Promise(resolve => setTimeout(() => resolve('two'), 100));
const promise9 = new Promise((resolve, reject) => setTimeout(() => reject(new Error('three')), 300));

Promise.race([promise7, promise8])
  .then(value => {
    console.log('Race winner:', value); // 'two' (settles first)
  });

Promise.race([promise7, promise9])
  .then(value => {
    console.log('Race winner:', value);
  })
  .catch(error => {
    console.error('Race loser:', error.message); // 'three' (rejects first)
  });

// Promise.allSettled example
const promise10 = Promise.resolve(33);
const promise11 = new Promise(resolve => setTimeout(() => resolve(66), 0));
const promise12 = new Promise((resolve, reject) => setTimeout(() => reject(new Error('An error')), 0));

Promise.allSettled([promise10, promise11, promise12])
  .then(results => {
    console.log('All promises settled:', results);
    // [
    //   { status: 'fulfilled', value: 33 },
    //   { status: 'fulfilled', value: 66 },
    //   { status: 'rejected', reason: Error: An error }
    // ]
    
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`Promise ${index + 1} fulfilled with: ${result.value}`);
      } else {
        console.log(`Promise ${index + 1} rejected with: ${result.reason.message}`);
      }
    });
  });

// Practical example: fetching multiple resources with Promise.all
function fetchMultipleUsers(userIds) {
  const promises = userIds.map(id => 
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then(response => response.json())
  );
  
  return Promise.all(promises);
}

fetchMultipleUsers([1, 2, 3])
  .then(users => {
    console.log('Users:', users);
  })
  .catch(error => {
    console.error('Error fetching users:', error);
  });

// Practical example: timeout with Promise.race
function fetchWithTimeout(url, timeout) {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Request timed out')), timeout)
  );
  
  return Promise.race([fetchPromise, timeoutPromise]);
}

fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/1', 100)
  .then(response => response.json())
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    console.error('Error:', error.message); // Likely 'Request timed out'
  });

// Practical example: checking status of multiple operations with Promise.allSettled
function checkOperationsStatus(operations) {
  const promises = operations.map(op => 
    op().then(
      result => ({ status: 'fulfilled', result }),
      error => ({ status: 'rejected', error })
    )
  );
  
  return Promise.all(promises);
}

const operations = [
  () => Promise.resolve('Operation 1 successful'),
  () => Promise.reject(new Error('Operation 2 failed')),
  () => Promise.resolve('Operation 3 successful')
];

checkOperationsStatus(operations)
  .then(results => {
    const successful = results.filter(r => r.status === 'fulfilled');
    const failed = results.filter(r => r.status === 'rejected');
    
    console.log(`${successful.length} operations succeeded`);
    console.log(`${failed.length} operations failed`);
  });
Asynchronous JavaScript

Microtasks vs Macrotasks

In JavaScript's event loop, tasks are divided into two categories: macrotasks (also called tasks) and microtasks. The distinction between them is important for understanding the order of execution in asynchronous operations.

Macrotasks (Tasks):

  • Include setTimeout, setInterval, I/O operations, UI rendering
  • Executed one at a time in a single event loop cycle
  • After each macrotask, the event loop checks for microtasks

Microtasks:

  • Include Promise callbacks, queueMicrotask, mutation observers
  • Executed immediately after the current script completes
  • All microtasks in the queue are executed before the next macrotask

Execution order:

  1. Execute the current script (synchronous code)
  2. Execute all microtasks in the microtask queue
  3. Execute one macrotask from the macrotask queue
  4. Repeat steps 2-3
console.log('Script start');

setTimeout(() => {
  console.log('Macrotask 1');
}, 0);

Promise.resolve().then(() => {
  console.log('Microtask 1');
});

Promise.resolve().then(() => {
  console.log('Microtask 2');
});

setTimeout(() => {
  console.log('Macrotask 2');
}, 0);

console.log('Script end');

// Output order:
// 'Script start'
// 'Script end'
// 'Microtask 1'
// 'Microtask 2'
// 'Macrotask 1'
// 'Macrotask 2'

// More complex example with nested microtasks
console.log('Script start');

setTimeout(() => {
  console.log('Macrotask 1');
  
  Promise.resolve().then(() => {
    console.log('Microtask in Macrotask 1');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('Microtask 1');
  
  Promise.resolve().then(() => {
    console.log('Nested Microtask');
  });
});

Promise.resolve().then(() => {
  console.log('Microtask 2');
});

console.log('Script end');

// Output order:
// 'Script start'
// 'Script end'
// 'Microtask 1'
// 'Microtask 2'
// 'Nested Microtask'
// 'Macrotask 1'
// 'Microtask in Macrotask 1'

// Example with async/await
async function asyncFunction() {
  console.log('Async function start');
  
  await Promise.resolve();
  
  console.log('After first await');
  
  await Promise.resolve();
  
  console.log('After second await');
}

console.log('Script start');

asyncFunction();

setTimeout(() => {
  console.log('Macrotask');
}, 0);

Promise.resolve().then(() => {
  console.log('Microtask');
});

console.log('Script end');

// Output order:
// 'Script start'
// 'Async function start'
// 'Script end'
// 'Microtask'
// 'After first await'
// 'After second await'
// 'Macrotask'

// Practical example: understanding why microtasks run before macrotasks
function microtaskExample() {
  console.log('Start');
  
  setTimeout(() => {
    console.log('Timeout');
  }, 0);
  
  Promise.resolve().then(() => {
    console.log('Promise');
  });
  
  console.log('End');
}

microtaskExample();
// 'Start', 'End', 'Promise', 'Timeout'

// Practical example: using queueMicrotask
function processItems(items) {
  items.forEach(item => {
    queueMicrotask(() => {
      console.log('Processing:', item);
    });
  });
}

processItems(['Item 1', 'Item 2', 'Item 3']);

setTimeout(() => {
  console.log('All items processed');
}, 0);

// 'Processing: Item 1', 'Processing: Item 2', 'Processing: Item 3', 'All items processed'

// Practical example: infinite loop with microtasks
function infiniteMicrotasks() {
  let counter = 0;
  
  function runMicrotask() {
    counter++;
    
    if (counter < 10) {
      queueMicrotask(runMicrotask);
    } else {
      console.log('Microtasks completed');
    }
  }
  
  queueMicrotask(runMicrotask);
  
  setTimeout(() => {
    console.log('Timeout (will run after all microtasks)');
  }, 0);
}

infiniteMicrotasks();
// 'Microtasks completed', 'Timeout (will run after all microtasks)'
Asynchronous JavaScript

Fetch and AbortController

The Fetch API provides a modern interface for making HTTP requests. AbortController is a companion API that allows you to abort fetch requests when they are no longer needed, which is useful for preventing unnecessary network traffic and avoiding race conditions.

Fetch API:

  • Provides a global fetch() method for making HTTP requests
  • Returns a Promise that resolves to the Response object
  • Supports various options for customizing requests
  • Better error handling compared to XMLHttpRequest

AbortController:

  • Provides a way to abort fetch requests
  • Consists of an AbortController object and an AbortSignal object
  • The AbortSignal is passed to the fetch request
  • Calling abort() on the AbortController aborts the request
// Basic fetch example
fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    console.log('Post:', data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

// Fetch with POST request
fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'foo',
    body: 'bar',
    userId: 1,
  }),
})
  .then(response => response.json())
  .then(data => {
    console.log('Success:', data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

// Using AbortController to abort a fetch request
const controller = new AbortController();
const signal = controller.signal;

fetch('https://jsonplaceholder.typicode.com/posts/1', { 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 request after 100ms
setTimeout(() => {
  controller.abort();
}, 100);

// Practical example: implementing a timeout with AbortController
function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const { signal } = controller;
  
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeout);
  
  return fetch(url, { ...options, signal })
    .finally(() => {
      clearTimeout(timeoutId);
    });
}

fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/1', {}, 100)
  .then(response => response.json())
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request timed out');
    } else {
      console.error('Error:', error);
    }
  });

// Practical example: implementing a reusable fetch function with abort capability
class FetchManager {
  constructor() {
    this.controllers = new Map();
  }
  
  fetch(url, options = {}, key = 'default') {
    // Abort any existing request with the same key
    if (this.controllers.has(key)) {
      this.controllers.get(key).abort();
    }
    
    const controller = new AbortController();
    this.controllers.set(key, controller);
    
    return fetch(url, { ...options, signal: controller.signal })
      .finally(() => {
        this.controllers.delete(key);
      });
  }
  
  abort(key) {
    if (this.controllers.has(key)) {
      this.controllers.get(key).abort();
      this.controllers.delete(key);
    }
  }
  
  abortAll() {
    this.controllers.forEach(controller => {
      controller.abort();
    });
    this.controllers.clear();
  }
}

const fetchManager = new FetchManager();

// Make a request
fetchManager.fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json())
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request was aborted');
    } else {
      console.error('Error:', error);
    }
  });

// Abort the request
fetchManager.abort('default');

// Practical example: implementing a search with debouncing and abort
function createSearchAPI() {
  const controller = new AbortController();
  let debounceTimeout;
  
  return function search(query) {
    // Clear previous timeout
    clearTimeout(debounceTimeout);
    
    // Abort previous request
    controller.abort();
    
    // Set new timeout
    debounceTimeout = setTimeout(() => {
      if (query.trim() === '') return;
      
      fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal
      })
        .then(response => response.json())
        .then(results => {
          console.log('Search results:', results);
        })
        .catch(error => {
          if (error.name !== 'AbortError') {
            console.error('Search error:', error);
          }
        });
    }, 300);
  };
}

const search = createSearchAPI();

// Simulate user typing
search('java');
search('javascript'); // Previous request for 'java' will be aborted
search('javascript tutorial'); // Previous request for 'javascript' will be aborted

// Practical example: implementing retry logic with AbortController
async function fetchWithRetry(url, options = {}, maxRetries = 3, retryDelay = 1000) {
  let lastError;
  
  for (let i = 0; i <= maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      
      return response;
    } catch (error) {
      lastError = error;
      
      if (i < maxRetries) {
        console.log(`Retry ${i + 1}/${maxRetries} after ${retryDelay}ms`);
        await new Promise(resolve => setTimeout(resolve, retryDelay));
      }
    }
  }
  
  throw lastError;
}

fetchWithRetry('https://jsonplaceholder.typicode.com/posts/999999')
  .then(response => response.json())
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    console.error('All retries failed:', error);
  });
Error Handling

Error handling in async code

Error handling in asynchronous JavaScript is crucial for building robust applications. There are several patterns and techniques for handling errors in async code, depending on whether you're using callbacks, Promises, or async/await.

Error Handling with Callbacks:

  • Node.js-style callbacks use the first argument for errors
  • Check if the error argument exists before processing results

Error Handling with Promises:

  • Use .catch() to handle rejected Promises
  • Errors propagate down the Promise chain until caught

Error Handling with async/await:

  • Use try/catch blocks to handle errors
  • Errors in awaited Promises are thrown as exceptions
// Error handling with callbacks
function fetchDataCallback(callback) {
  setTimeout(() => {
    const success = Math.random() > 0.5;
    
    if (success) {
      callback(null, { data: 'Some data' });
    } else {
      callback(new Error('Failed to fetch data'));
    }
  }, 1000);
}

fetchDataCallback((error, data) => {
  if (error) {
    console.error('Error:', error.message);
    return;
  }
  
  console.log('Data:', data);
});

// Error handling with Promises
function fetchDataPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.5;
      
      if (success) {
        resolve({ data: 'Some data' });
      } else {
        reject(new Error('Failed to fetch data'));
      }
    }, 1000);
  });
}

fetchDataPromise()
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    console.error('Error:', error.message);
  });

// Error handling with async/await
async function fetchDataAsync() {
  try {
    const data = await fetchDataPromise();
    console.log('Data:', data);
  } catch (error) {
    console.error('Error:', error.message);
  }
}

fetchDataAsync();

// Handling errors in Promise chains
function processData(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!data) {
        reject(new Error('No data to process'));
        return;
      }
      
      resolve({ processedData: data.data.toUpperCase() });
    }, 500);
  });
}

fetchDataPromise()
  .then(data => processData(data))
  .then(processedData => {
    console.log('Processed data:', processedData);
  })
  .catch(error => {
    console.error('Error in processing:', error.message);
  });

// Handling errors in async/await with multiple operations
async function fetchAndProcessData() {
  try {
    const data = await fetchDataPromise();
    const processedData = await processData(data);
    console.log('Processed data:', processedData);
  } catch (error) {
    console.error('Error:', error.message);
  }
}

fetchAndProcessData();

// Handling specific error types
class NetworkError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NetworkError';
  }
}

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ValidationError';
  }
}

function validateData(data) {
  if (!data || !data.data) {
    throw new ValidationError('Invalid data structure');
  }
  return true;
}

async function fetchAndValidateData() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
    
    if (!response.ok) {
      throw new NetworkError(`HTTP error! Status: ${response.status}`);
    }
    
    const data = await response.json();
    validateData(data);
    
    return data;
  } catch (error) {
    if (error instanceof NetworkError) {
      console.error('Network error:', error.message);
    } else if (error instanceof ValidationError) {
      console.error('Validation error:', error.message);
    } else {
      console.error('Unexpected error:', error.message);
    }
    
    throw error; // Re-throw for further handling
  }
}

fetchAndValidateData()
  .then(data => {
    console.log('Valid data:', data);
  })
  .catch(error => {
    console.log('Handled error');
  });

// Global error handling for unhandled Promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

// Error handling with Promise.finally
function fetchDataWithCleanup() {
  return fetchDataPromise()
    .then(data => {
      console.log('Data fetched successfully');
      return data;
    })
    .catch(error => {
      console.error('Error fetching data:', error.message);
      throw error;
    })
    .finally(() => {
      console.log('Cleanup operations');
    });
}

fetchDataWithCleanup()
  .then(data => {
    console.log('Processing data:', data);
  })
  .catch(error => {
    console.log('Error in processing');
  });

// Practical example: implementing a retry mechanism with error handling
async function fetchWithRetry(url, maxRetries = 3, retryDelay = 1000) {
  let lastError;
  
  for (let i = 0; i <= maxRetries; i++) {
    try {
      const response = await fetch(url);
      
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      lastError = error;
      
      if (i < maxRetries) {
        console.log(`Retry ${i + 1}/${maxRetries} after ${retryDelay}ms`);
        await new Promise(resolve => setTimeout(resolve, retryDelay));
      }
    }
  }
  
  throw lastError;
}

fetchWithRetry('https://jsonplaceholder.typicode.com/posts/999999')
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    console.error('All retries failed:', error.message);
  });
Modules

ES Modules vs CommonJS

JavaScript has two main module systems: ES Modules (ESM) and CommonJS (CJS). ES Modules are the standard in modern JavaScript and are supported by browsers and recent versions of Node.js, while CommonJS is the traditional module system used by Node.js.

ES Modules (ESM):

  • Uses import and export keywords
  • Static structure - imports and exports are determined at compile time
  • Supports tree shaking (dead code elimination)
  • Asynchronous loading
  • Can have named exports or a default export

CommonJS (CJS):

  • Uses require() and module.exports
  • Dynamic structure - modules can be loaded conditionally
  • Synchronous loading
  • Single value export (object containing all exports)
// ES Module (math.mjs)
export const PI = 3.14159;

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

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

export default function multiply(a, b) {
  return a * b;
}

// Using ES Module (app.mjs)
import multiply, { add, subtract, PI } from './math.mjs';
import * as math from './math.mjs';

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(subtract(5, 3)); // 2
console.log(multiply(5, 3)); // 15
console.log(math.add(5, 3)); // 8

// CommonJS Module (math.js)
const PI = 3.14159;

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

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

function multiply(a, b) {
  return a * b;
}

module.exports = {
  PI,
  add,
  subtract,
  multiply
};

// Using CommonJS Module (app.js)
const math = require('./math.js');
const { add, subtract, PI } = require('./math.js');

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(subtract(5, 3)); // 2
console.log(math.multiply(5, 3)); // 15

// Dynamic imports with ES Modules
async function loadModule() {
  const { default: multiply } = await import('./math.mjs');
  console.log(multiply(5, 3)); // 15
}

loadModule();

// Conditional requires with CommonJS
let math;
if (process.env.NODE_ENV === 'development') {
  math = require('./math-dev.js');
} else {
  math = require('./math-prod.js');
}

console.log(math.add(5, 3));

// Mixed imports (importing CommonJS in ES Module)
// package.json needs "type": "module" for ES Modules
// To import CommonJS in ES Module:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const math = require('./math.js');

// Circular dependencies
// ES Module (a.mjs)
import { b } from './b.mjs';
export const a = 'A';
console.log(a, b);

// ES Module (b.mjs)
import { a } from './a.mjs';
export const b = 'B';
console.log(a, b);

// CommonJS (a.js)
const b = require('./b.js');
module.exports.a = 'A';
console.log(module.exports.a, b.b);

// CommonJS (b.js)
const a = require('./a.js');
module.exports.b = 'B';
console.log(a.a, module.exports.b);

// Exporting a class with ES Modules
export class Calculator {
  add(a, b) {
    return a + b;
  }
}

// Importing a class with ES Modules
import { Calculator } from './calculator.mjs';
const calc = new Calculator();
console.log(calc.add(5, 3));

// Exporting a class with CommonJS
class Calculator {
  add(a, b) {
    return a + b;
  }
}

module.exports = Calculator;

// Importing a class with CommonJS
const Calculator = require('./calculator.js');
const calc = new Calculator();
console.log(calc.add(5, 3));
DOM & Browser

What is event delegation?

Event delegation is a technique in JavaScript where you attach a single event listener to a parent element to handle events for multiple child elements. This approach takes advantage of event bubbling, where events propagate up the DOM tree from the target element to its ancestors.

Benefits of event delegation:

  • Reduces the number of event listeners, improving performance
  • Simplifies code by centralizing event handling logic
  • Automatically handles dynamically added elements
  • Reduces memory usage

How event delegation works:

  1. Attach an event listener to a parent element
  2. When an event occurs on a child element, it bubbles up to the parent
  3. The parent's event handler checks the event target to determine which child triggered the event
  4. Appropriate action is taken based on the target element
// HTML structure
/*
<ul id="menu">
  <li><a href="#" data-page="home">Home</a></li>
  <li><a href="#" data-page="about">About</a></li>
  <li><a href="#" data-page="contact">Contact</a></li>
</ul>
*/

// Traditional approach (multiple event listeners)
const menuItems = document.querySelectorAll('#menu li a');
menuItems.forEach(item => {
  item.addEventListener('click', function(event) {
    event.preventDefault();
    const page = this.getAttribute('data-page');
    console.log('Navigating to:', page);
  });
});

// Event delegation approach (single event listener)
const menu = document.getElementById('menu');
menu.addEventListener('click', function(event) {
  // Check if the clicked element is an anchor tag
  if (event.target.tagName === 'A') {
    event.preventDefault();
    const page = event.target.getAttribute('data-page');
    console.log('Navigating to:', page);
  }
});

// More specific event delegation with matches
menu.addEventListener('click', function(event) {
  const link = event.target.closest('a');
  
  if (link && link.matches('[data-page]')) {
    event.preventDefault();
    const page = link.getAttribute('data-page');
    console.log('Navigating to:', page);
  }
});

// Practical example: handling a dynamic list
function addListItem(text) {
  const list = document.getElementById('dynamic-list');
  const item = document.createElement('li');
  item.textContent = text;
  list.appendChild(item);
}

// Event delegation for the dynamic list
document.getElementById('dynamic-list').addEventListener('click', function(event) {
  if (event.target.tagName === 'LI') {
    console.log('Clicked item:', event.target.textContent);
    event.target.classList.toggle('active');
  }
});

// Add items dynamically
addListItem('Item 1');
addListItem('Item 2');
addListItem('Item 3');

// Practical example: implementing a tab system
const tabContainer = document.getElementById('tabs');
const tabContent = document.getElementById('tab-content');

// Event delegation for tabs
tabContainer.addEventListener('click', function(event) {
  const tab = event.target.closest('.tab');
  
  if (tab) {
    // Remove active class from all tabs
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
    
    // Add active class to clicked tab
    tab.classList.add('active');
    
    // Show corresponding content
    const tabId = tab.getAttribute('data-tab');
    document.querySelectorAll('.tab-pane').forEach(pane => {
      pane.classList.remove('active');
    });
    document.getElementById(tabId).classList.add('active');
  }
});

// Practical example: handling form events with delegation
const form = document.getElementById('my-form');

form.addEventListener('click', function(event) {
  if (event.target.matches('.add-field')) {
    event.preventDefault();
    const field = document.createElement('input');
    field.type = 'text';
    field.name = 'dynamic-field';
    field.placeholder = 'Dynamic field';
    
    form.insertBefore(field, event.target);
  }
  
  if (event.target.matches('.remove-field')) {
    event.preventDefault();
    const fields = form.querySelectorAll('input[name="dynamic-field"]');
    if (fields.length > 0) {
      fields[fields.length - 1].remove();
    }
  }
});

// Practical example: implementing a context menu
document.addEventListener('contextmenu', function(event) {
  event.preventDefault();
  
  const contextMenu = document.getElementById('context-menu');
  contextMenu.style.display = 'block';
  contextMenu.style.left = `${event.pageX}px`;
  contextMenu.style.top = `${event.pageY}px`;
});

document.addEventListener('click', function() {
  document.getElementById('context-menu').style.display = 'none';
});

document.getElementById('context-menu').addEventListener('click', function(event) {
  if (event.target.tagName === 'BUTTON') {
    const action = event.target.getAttribute('data-action');
    console.log('Context menu action:', action);
  }
});

// Practical example: handling keyboard shortcuts
document.addEventListener('keydown', function(event) {
  // Ctrl/Cmd + S to save
  if ((event.ctrlKey || event.metaKey) && event.key === 's') {
    event.preventDefault();
    console.log('Saving...');
  }
  
  // Escape to close modal
  if (event.key === 'Escape') {
    const modal = document.querySelector('.modal.active');
    if (modal) {
      modal.classList.remove('active');
    }
  }
});
DOM & Browser

DOM vs BOM

The Document Object Model (DOM) and the Browser Object Model (BOM) are two important concepts in browser JavaScript. While they are related, they serve different purposes and provide access to different parts of the browser environment.

Document Object Model (DOM):

  • Represents the structure of HTML and XML documents
  • Provides a programming interface for documents
  • Represents documents as a tree of nodes
  • Allows programs to dynamically access and update content, structure, and style
  • Core object is document

Browser Object Model (BOM):

  • Represents the browser window and its components
  • Provides access to browser functionality
  • Not standardized, varies between browsers
  • Includes objects like window, navigator, location, history
  • Core object is window
// DOM examples
// Accessing elements
const header = document.getElementById('header');
const paragraphs = document.getElementsByTagName('p');
const buttons = document.getElementsByClassName('btn');
const firstButton = document.querySelector('.btn');
const allButtons = document.querySelectorAll('.btn');

// Modifying elements
header.textContent = 'New Header';
header.innerHTML = '<em>New Header</em>';
header.style.color = 'blue';
header.classList.add('highlight');
header.setAttribute('data-id', '123');

// Creating elements
const newDiv = document.createElement('div');
newDiv.textContent = 'New div';
document.body.appendChild(newDiv);

// Event handling (DOM)
document.getElementById('myButton').addEventListener('click', function() {
  console.log('Button clicked');
});

// BOM examples
// Window object
console.log(window.innerWidth); // Browser window width
console.log(window.innerHeight); // Browser window height
window.open('https://example.com', '_blank');
window.close();
window.alert('Hello, world!');
window.setTimeout(() => console.log('Delayed message'), 1000);

// Location object
console.log(window.location.href); // Current URL
console.log(window.location.hostname); // Domain name
console.log(window.location.pathname); // Path
window.location.href = 'https://example.com'; // Navigate to new URL
window.location.reload(); // Reload page

// Navigator object
console.log(navigator.userAgent); // Browser user agent
console.log(navigator.platform); // Operating system
console.log(navigator.language); // Browser language
navigator.geolocation.getCurrentPosition(position => {
  console.log('Latitude:', position.coords.latitude);
  console.log('Longitude:', position.coords.longitude);
});

// History object
console.log(history.length); // Number of URLs in history
history.back(); // Go back
history.forward(); // Go forward
history.go(-1); // Go back one page

// Screen object
console.log(screen.width); // Screen width
console.log(screen.height); // Screen height
console.log(screen.colorDepth); // Color depth

// LocalStorage (BOM API)
localStorage.setItem('username', 'john');
const username = localStorage.getItem('username');
localStorage.removeItem('username');
localStorage.clear();

// Practical example: responsive design with BOM
function checkScreenSize() {
  if (window.innerWidth < 768) {
    document.body.classList.add('mobile');
    document.body.classList.remove('desktop');
  } else {
    document.body.classList.add('desktop');
    document.body.classList.remove('mobile');
  }
}

window.addEventListener('resize', checkScreenSize);
checkScreenSize();

// Practical example: using BOM to detect browser features
const features = {
  touch: 'ontouchstart' in window,
  geolocation: 'geolocation' in navigator,
  localStorage: 'localStorage' in window,
  webgl: (() => {
    try {
      const canvas = document.createElement('canvas');
      return !!(window.WebGLRenderingContext && 
        (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')));
    } catch (e) {
      return false;
    }
  })()
};

console.log('Browser features:', features);

// Practical example: custom confirmation dialog using BOM
function customConfirm(message) {
  return new Promise((resolve) => {
    const modal = document.createElement('div');
    modal.className = 'confirm-modal';
    modal.innerHTML = `
      <div class="confirm-content">
        <p>${message}</p>
        <div class="confirm-buttons">
          <button class="confirm-yes">Yes</button>
          <button class="confirm-no">No</button>
        </div>
      </div>
    `;
    
    document.body.appendChild(modal);
    
    modal.querySelector('.confirm-yes').addEventListener('click', () => {
      document.body.removeChild(modal);
      resolve(true);
    });
    
    modal.querySelector('.confirm-no').addEventListener('click', () => {
      document.body.removeChild(modal);
      resolve(false);
    });
  });
}

customConfirm('Are you sure?').then(result => {
  console.log('User chose:', result ? 'Yes' : 'No');
});
DOM & Browser

localStorage vs sessionStorage vs cookies

Web browsers provide several mechanisms for storing data on the client side: localStorage, sessionStorage, and cookies. Each has different characteristics, use cases, and limitations.

localStorage:

  • Persistent storage that survives browser restarts
  • Capacity of around 5-10 MB per domain
  • Data is stored as strings
  • Not sent with HTTP requests
  • Same-origin policy applies

sessionStorage:

  • Session-based storage that is cleared when the tab/window is closed
  • Capacity of around 5 MB per domain
  • Data is stored as strings
  • Not sent with HTTP requests
  • Separate storage for each tab/window

Cookies:

  • Small pieces of data sent with HTTP requests
  • Limited to around 4 KB per cookie
  • Can have expiration dates
  • Can be secure (HTTPS only) or HTTP only
  • Subject to cross-site request forgery (CSRF) attacks
// localStorage examples
// Storing data
localStorage.setItem('username', 'john');
localStorage.setItem('preferences', JSON.stringify({
  theme: 'dark',
  language: 'en'
}));

// Retrieving data
const username = localStorage.getItem('username');
const preferences = JSON.parse(localStorage.getItem('preferences') || '{}');

console.log(username); // 'john'
console.log(preferences.theme); // 'dark'

// Removing data
localStorage.removeItem('username');

// Clearing all data
localStorage.clear();

// Checking if an item exists
if (localStorage.getItem('token')) {
  console.log('User is logged in');
}

// sessionStorage examples
// Storing data
sessionStorage.setItem('currentPage', 'dashboard');
sessionStorage.setItem('formData', JSON.stringify({
  name: 'John',
  email: 'john@example.com'
}));

// Retrieving data
const currentPage = sessionStorage.getItem('currentPage');
const formData = JSON.parse(sessionStorage.getItem('formData') || '{}');

console.log(currentPage); // 'dashboard'
console.log(formData.name); // 'John'

// Cookies examples
// Setting a cookie
document.cookie = 'username=john; expires=Fri, 31 Dec 2023 23:59:59 GMT; path=/';

// Setting a secure cookie
document.cookie = 'token=abc123; expires=Fri, 31 Dec 2023 23:59:59 GMT; path=/; secure; HttpOnly';

// Getting all cookies
function getCookies() {
  const cookies = {};
  document.cookie.split(';').forEach(cookie => {
    const [name, value] = cookie.trim().split('=');
    if (name) {
      cookies[name] = decodeURIComponent(value);
    }
  });
  return cookies;
}

const cookies = getCookies();
console.log(cookies.username); // 'john'

// Getting a specific cookie
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) {
    return parts.pop().split(';').shift();
  }
  return null;
}

const username = getCookie('username');
console.log(username); // 'john'

// Deleting a cookie
document.cookie = 'username=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';

// Practical example: user preferences with localStorage
const preferences = {
  theme: 'light',
  language: 'en',
  fontSize: 16
};

function savePreferences(prefs) {
  localStorage.setItem('preferences', JSON.stringify(prefs));
}

function loadPreferences() {
  const saved = localStorage.getItem('preferences');
  return saved ? JSON.parse(saved) : preferences;
}

function applyPreferences(prefs) {
  document.body.classList.toggle('dark-theme', prefs.theme === 'dark');
  document.documentElement.lang = prefs.language;
  document.documentElement.style.fontSize = `${prefs.fontSize}px`;
}

// Save preferences when they change
function updatePreference(key, value) {
  const prefs = loadPreferences();
  prefs[key] = value;
  savePreferences(prefs);
  applyPreferences(prefs);
}

// Load and apply preferences on page load
applyPreferences(loadPreferences());

// Practical example: form data persistence with sessionStorage
const form = document.getElementById('my-form');

// Load form data from sessionStorage
function loadFormData() {
  const formData = JSON.parse(sessionStorage.getItem('formData') || '{}');
  
  Object.keys(formData).forEach(key => {
    const input = form.elements[key];
    if (input) {
      input.value = formData[key];
    }
  });
}

// Save form data to sessionStorage
function saveFormData() {
  const formData = {};
  
  Array.from(form.elements).forEach(input => {
    if (input.name) {
      formData[input.name] = input.value;
    }
  });
  
  sessionStorage.setItem('formData', JSON.stringify(formData));
}

// Load form data on page load
loadFormData();

// Save form data on input
form.addEventListener('input', saveFormData);

// Clear form data on submit
form.addEventListener('submit', () => {
  sessionStorage.removeItem('formData');
});

// Practical example: authentication with cookies
function setAuthCookie(token, expiresInDays = 7) {
  const date = new Date();
  date.setTime(date.getTime() + (expiresInDays * 24 * 60 * 60 * 1000));
  const expires = `expires=${date.toUTCString()}`;
  
  document.cookie = `authToken=${token}; ${expires}; path=/; secure; HttpOnly; SameSite=Strict`;
}

function getAuthCookie() {
  return getCookie('authToken');
}

function clearAuthCookie() {
  document.cookie = 'authToken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
}

// Check if user is authenticated
function isAuthenticated() {
  return !!getAuthCookie();
}

// Practical example: using localStorage for caching API responses
function cacheResponse(key, data, maxAgeMinutes = 5) {
  const cacheEntry = {
    data,
    timestamp: Date.now(),
    maxAge: maxAgeMinutes * 60 * 1000
  };
  
  localStorage.setItem(key, JSON.stringify(cacheEntry));
}

function getCachedResponse(key) {
  const cached = localStorage.getItem(key);
  
  if (!cached) {
    return null;
  }
  
  const { data, timestamp, maxAge } = JSON.parse(cached);
  
  if (Date.now() - timestamp > maxAge) {
    localStorage.removeItem(key);
    return null;
  }
  
  return data;
}

async function fetchWithCache(url) {
  const cacheKey = `cache_${url}`;
  const cachedData = getCachedResponse(cacheKey);
  
  if (cachedData) {
    console.log('Returning cached data');
    return cachedData;
  }
  
  console.log('Fetching fresh data');
  const response = await fetch(url);
  const data = await response.json();
  
  cacheResponse(cacheKey, data);
  return data;
}

fetchWithCache('https://jsonplaceholder.typicode.com/posts/1')
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));
Design Patterns

Module Pattern

The Module Pattern is a design pattern that provides a way to encapsulate and organize code into self-contained units with private and public members. It helps in creating clean, reusable, and maintainable code by reducing global namespace pollution.

Key concepts of the Module Pattern:

  • Encapsulation of private variables and functions
  • Exposing a public API
  • Creating a singleton-like structure
  • Separating concerns and organizing code logically

Types of Module Patterns:

  • Object Literal Notation: Simple module using object literals
  • Module Pattern with IIFE: Using Immediately Invoked Function Expressions
  • Revealing Module Pattern: Explicitly revealing public members
  • ES6 Modules: Native module system in modern JavaScript
// Object Literal Notation
const calculatorModule = {
  add: function(a, b) {
    return a + b;
  },
  
  subtract: function(a, b) {
    return a - b;
  },
  
  multiply: function(a, b) {
    return a * b;
  }
};

console.log(calculatorModule.add(5, 3)); // 8

// Module Pattern with IIFE
const counterModule = (function() {
  let count = 0;
  
  function increment() {
    count++;
    return count;
  }
  
  function decrement() {
    count--;
    return count;
  }
  
  function getCount() {
    return count;
  }
  
  return {
    increment,
    decrement,
    getCount
  };
})();

console.log(counterModule.getCount()); // 0
console.log(counterModule.increment()); // 1
console.log(counterModule.increment()); // 2
console.log(counterModule.getCount()); // 2

// Revealing Module Pattern
const userModule = (function() {
  let users = [];
  
  function addUser(user) {
    users.push(user);
  }
  
  function getUser(id) {
    return users.find(user => user.id === id);
  }
  
  function getAllUsers() {
    return [...users];
  }
  
  function removeUser(id) {
    users = users.filter(user => user.id !== id);
  }
  
  return {
    add: addUser,
    get: getUser,
    getAll: getAllUsers,
    remove: removeUser
  };
})();

userModule.add({ id: 1, name: 'John' });
userModule.add({ id: 2, name: 'Jane' });

console.log(userModule.getAll());
console.log(userModule.get(1));

// Module Pattern with private and public members
const shoppingCart = (function() {
  // Private members
  let cart = [];
  let totalPrice = 0;
  
  // Private function
  function calculateTotal() {
    totalPrice = cart.reduce((total, item) => total + (item.price * item.quantity), 0);
  }
  
  // Public API
  return {
    addItem: function(item) {
      const existingItem = cart.find(cartItem => cartItem.id === item.id);
      
      if (existingItem) {
        existingItem.quantity += item.quantity;
      } else {
        cart.push({ ...item, quantity: item.quantity || 1 });
      }
      
      calculateTotal();
    },
    
    removeItem: function(id) {
      cart = cart.filter(item => item.id !== id);
      calculateTotal();
    },
    
    updateQuantity: function(id, quantity) {
      const item = cart.find(item => item.id === id);
      
      if (item) {
        item.quantity = quantity;
        calculateTotal();
      }
    },
    
    getItems: function() {
      return [...cart];
    },
    
    getTotal: function() {
      return totalPrice;
    },
    
    clear: function() {
      cart = [];
      totalPrice = 0;
    }
  };
})();

shoppingCart.addItem({ id: 1, name: 'Product 1', price: 10 });
shoppingCart.addItem({ id: 2, name: 'Product 2', price: 20, quantity: 2 });

console.log(shoppingCart.getItems());
console.log(shoppingCart.getTotal()); // 50

// Module Pattern with namespace
const MyApp = MyApp || {};

MyApp.utils = (function() {
  function formatDate(date) {
    return new Date(date).toLocaleDateString();
  }
  
  function debounce(func, wait) {
    let timeout;
    return function(...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }
  
  return {
    formatDate,
    debounce
  };
})();

MyApp.services = (function() {
  function fetchUser(id) {
    return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then(response => response.json());
  }
  
  return {
    fetchUser
  };
})();

MyApp.utils.debounce(() => {
  console.log('Debounced function');
}, 300)();

// ES6 Modules (modern approach)
// math.mjs
export function add(a, b) {
  return a + b;
}

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

export default function multiply(a, b) {
  return a * b;
}

// app.mjs
import multiply, { add, subtract } from './math.mjs';

console.log(add(5, 3)); // 8
console.log(multiply(5, 3)); // 15

// Practical example: implementing a logger module
const Logger = (function() {
  const logLevel = {
    DEBUG: 0,
    INFO: 1,
    WARN: 2,
    ERROR: 3
  };
  
  let currentLevel = logLevel.INFO;
  const logs = [];
  
  function formatMessage(level, message) {
    const timestamp = new Date().toISOString();
    const levelName = Object.keys(logLevel).find(key => logLevel[key] === level);
    return `[${timestamp}] [${levelName}] ${message}`;
  }
  
  function log(level, message) {
    if (level >= currentLevel) {
      const formattedMessage = formatMessage(level, message);
      console.log(formattedMessage);
      logs.push({ level, message, timestamp: new Date() });
    }
  }
  
  return {
    setLevel: function(level) {
      currentLevel = level;
    },
    
    debug: function(message) {
      log(logLevel.DEBUG, message);
    },
    
    info: function(message) {
      log(logLevel.INFO, message);
    },
    
    warn: function(message) {
      log(logLevel.WARN, message);
    },
    
    error: function(message) {
      log(logLevel.ERROR, message);
    },
    
    getLogs: function() {
      return [...logs];
    },
    
    clearLogs: function() {
      logs.length = 0;
    }
  };
})();

Logger.setLevel(Logger.logLevel.DEBUG);
Logger.debug('Debug message');
Logger.info('Info message');
Logger.warn('Warning message');
Logger.error('Error message');
Design Patterns

Observer Pattern

The Observer Pattern is a design pattern where an object (called the subject) maintains a list of its dependents (called observers) and notifies them automatically of any state changes. This pattern is commonly used in event handling systems and is fundamental to many JavaScript frameworks.

Key components of the Observer Pattern:

  • Subject: The object being observed
  • Observer: Objects that want to be notified of changes
  • Notification: The mechanism by which the subject notifies observers

Benefits of the Observer Pattern:

  • Loose coupling between subjects and observers
  • Support for broadcast communication
  • Dynamic relationships between objects
// Basic implementation of the Observer Pattern
class Subject {
  constructor() {
    this.observers = [];
  }
  
  subscribe(observer) {
    this.observers.push(observer);
  }
  
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }
  
  update(data) {
    console.log(`${this.name} received data: ${data}`);
  }
}

const subject = new Subject();

const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');

subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify('Hello Observers!');

subject.unsubscribe(observer1);
subject.notify('Observer 1 is gone');

// Practical example: implementing a weather station
class WeatherStation {
  constructor() {
    this.temperature = 0;
    this.humidity = 0;
    this.observers = [];
  }
  
  setMeasurements(temperature, humidity) {
    this.temperature = temperature;
    this.humidity = humidity;
    this.notifyObservers();
  }
  
  subscribe(observer) {
    this.observers.push(observer);
  }
  
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  
  notifyObservers() {
    this.observers.forEach(observer => observer.update(this.temperature, this.humidity));
  }
}

class WeatherDisplay {
  constructor(name) {
    this.name = name;
  }
  
  update(temperature, humidity) {
    console.log(`${this.name}: Temperature is ${temperature}°C and humidity is ${humidity}%`);
  }
}

const weatherStation = new WeatherStation();

const display1 = new WeatherDisplay('Display 1');
const display2 = new WeatherDisplay('Display 2');

weatherStation.subscribe(display1);
weatherStation.subscribe(display2);

weatherStation.setMeasurements(25, 60);
weatherStation.setMeasurements(30, 50);

// Practical example: implementing an event emitter
class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  off(event, callback) {
    if (!this.events[event]) return;
    
    this.events[event] = this.events[event].filter(cb => cb !== callback);
  }
  
  emit(event, ...args) {
    if (!this.events[event]) return;
    
    this.events[event].forEach(callback => {
      callback(...args);
    });
  }
  
  once(event, callback) {
    const onceWrapper = (...args) => {
      callback(...args);
      this.off(event, onceWrapper);
    };
    
    this.on(event, onceWrapper);
  }
}

const emitter = new EventEmitter();

emitter.on('data', (data) => {
  console.log('Received data:', data);
});

emitter.on('error', (error) => {
  console.error('Error:', error.message);
});

emitter.once('init', () => {
  console.log('Initialized');
});

emitter.emit('init');
emitter.emit('data', { id: 1, value: 'test' });
emitter.emit('error', new Error('Something went wrong'));

// Practical example: implementing a simple store/state management
class Store {
  constructor(initialState = {}) {
    this.state = initialState;
    this.subscribers = [];
  }
  
  getState() {
    return this.state;
  }
  
  setState(updates) {
    this.state = { ...this.state, ...updates };
    this.notifySubscribers();
  }
  
  subscribe(subscriber) {
    this.subscribers.push(subscriber);
    
    return () => {
      this.subscribers = this.subscribers.filter(sub => sub !== subscriber);
    };
  }
  
  notifySubscribers() {
    this.subscribers.forEach(subscriber => subscriber(this.state));
  }
}

const store = new Store({ count: 0, name: 'Counter' });

const unsubscribe = store.subscribe(state => {
  console.log('State changed:', state);
});

store.setState({ count: 1 });
store.setState({ name: 'Updated Counter' });

unsubscribe();

store.setState({ count: 2 });

// Practical example: implementing a pub/sub system
class PubSub {
  constructor() {
    this.topics = {};
  }
  
  subscribe(topic, callback) {
    if (!this.topics[topic]) {
      this.topics[topic] = [];
    }
    
    const token = Symbol('token');
    this.topics[topic].push({ token, callback });
    
    return token;
  }
  
  unsubscribe(token) {
    for (const topic in this.topics) {
      this.topics[topic] = this.topics[topic].filter(sub => sub.token !== token);
    }
  }
  
  publish(topic, data) {
    if (!this.topics[topic]) return;
    
    this.topics[topic].forEach(({ callback }) => {
      callback(data);
    });
  }
}

const pubsub = new PubSub();

const token1 = pubsub.subscribe('user/login', user => {
  console.log('User logged in:', user.name);
});

const token2 = pubsub.subscribe('user/logout', () => {
  console.log('User logged out');
});

pubsub.publish('user/login', { id: 1, name: 'John' });
pubsub.publish('user/logout');

pubsub.unsubscribe(token1);
pubsub.publish('user/login', { id: 2, name: 'Jane' });

// Practical example: implementing a reactive data structure
class ReactiveValue {
  constructor(initialValue) {
    this._value = initialValue;
    this.subscribers = [];
  }
  
  get value() {
    return this._value;
  }
  
  set value(newValue) {
    if (this._value !== newValue) {
      this._value = newValue;
      this.notifySubscribers();
    }
  }
  
  subscribe(subscriber) {
    this.subscribers.push(subscriber);
    
    return () => {
      this.subscribers = this.subscribers.filter(sub => sub !== subscriber);
    };
  }
  
  notifySubscribers() {
    this.subscribers.forEach(subscriber => subscriber(this._value));
  }
}

const count = new ReactiveValue(0);

count.subscribe(value => {
  console.log('Count changed to:', value);
});

count.value = 1;
count.value = 2;
Design Patterns

Singleton Pattern

The Singleton Pattern is a design pattern that restricts the instantiation of a class to a single object. This is useful when exactly one object is needed to coordinate actions across the system. The pattern is often used for managing global state, configuration, or resource pools.

Key characteristics of the Singleton Pattern:

  • Ensures only one instance of a class exists
  • Provides a global point of access to that instance
  • Lazy initialization of the instance

Use cases for the Singleton Pattern:

  • Database connection pools
  • Logger instances
  • Configuration managers
  • Cache managers
// Basic Singleton implementation
class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance;
    }
    
    this.data = 'Singleton data';
    Singleton.instance = this;
  }
  
  getData() {
    return this.data;
  }
  
  setData(data) {
    this.data = data;
  }
}

const instance1 = new Singleton();
const instance2 = new Singleton();

console.log(instance1 === instance2); // true
console.log(instance1.getData()); // 'Singleton data'
instance2.setData('Updated data');
console.log(instance1.getData()); // 'Updated data'

// Singleton with IIFE
const ConfigManager = (function() {
  let instance;
  
  function createInstance() {
    return {
      config: {
        apiUrl: 'https://api.example.com',
        timeout: 5000
      },
      
      get(key) {
        return this.config[key];
      },
      
      set(key, value) {
        this.config[key] = value;
      }
    };
  }
  
  return {
    getInstance: function() {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    }
  };
})();

const config1 = ConfigManager.getInstance();
const config2 = ConfigManager.getInstance();

console.log(config1 === config2); // true
console.log(config1.get('apiUrl')); // 'https://api.example.com'
config2.set('apiUrl', 'https://new-api.example.com');
console.log(config1.get('apiUrl')); // 'https://new-api.example.com'

// Singleton with ES6 class
class DatabaseConnection {
  constructor() {
    if (DatabaseConnection.instance) {
      return DatabaseConnection.instance;
    }
    
    this.connection = this.connect();
    DatabaseConnection.instance = this;
  }
  
  connect() {
    console.log('Establishing database connection...');
    return { connected: true };
  }
  
  query(sql) {
    console.log(`Executing query: ${sql}`);
    return { results: [] };
  }
}

const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();

console.log(db1 === db2); // true
db1.query('SELECT * FROM users');

// Practical example: logger singleton
class Logger {
  constructor() {
    if (Logger.instance) {
      return Logger.instance;
    }
    
    this.logs = [];
    Logger.instance = this;
  }
  
  log(message) {
    const timestamp = new Date().toISOString();
    this.logs.push({ timestamp, message });
    console.log(`[${timestamp}] ${message}`);
  }
  
  getLogs() {
    return [...this.logs];
  }
  
  clearLogs() {
    this.logs = [];
  }
}

const logger1 = new Logger();
const logger2 = new Logger();

logger1.log('Application started');
logger2.log('User logged in');

console.log(logger1.getLogs().length); // 2
console.log(logger2.getLogs().length); // 2

// Practical example: cache manager singleton
class CacheManager {
  constructor() {
    if (CacheManager.instance) {
      return CacheManager.instance;
    }
    
    this.cache = new Map();
    CacheManager.instance = this;
  }
  
  set(key, value, ttl = 60000) {
    const expiry = Date.now() + ttl;
    this.cache.set(key, { value, expiry });
  }
  
  get(key) {
    const item = this.cache.get(key);
    
    if (!item) {
      return null;
    }
    
    if (Date.now() > item.expiry) {
      this.cache.delete(key);
      return null;
    }
    
    return item.value;
  }
  
  has(key) {
    return this.get(key) !== null;
  }
  
  delete(key) {
    return this.cache.delete(key);
  }
  
  clear() {
    this.cache.clear();
  }
  
  size() {
    return this.cache.size;
  }
}

const cache1 = new CacheManager();
const cache2 = new CacheManager();

cache1.set('user:1', { name: 'John', age: 30 });
console.log(cache2.get('user:1')); // { name: 'John', age: 30 }

// Practical example: theme manager singleton
class ThemeManager {
  constructor() {
    if (ThemeManager.instance) {
      return ThemeManager.instance;
    }
    
    this.theme = 'light';
    this.subscribers = [];
    ThemeManager.instance = this;
  }
  
  setTheme(theme) {
    if (this.theme !== theme) {
      this.theme = theme;
      this.notifySubscribers();
    }
  }
  
  getTheme() {
    return this.theme;
  }
  
  subscribe(callback) {
    this.subscribers.push(callback);
    
    return () => {
      this.subscribers = this.subscribers.filter(sub => sub !== callback);
    };
  }
  
  notifySubscribers() {
    this.subscribers.forEach(callback => callback(this.theme));
  }
}

const themeManager1 = new ThemeManager();
const themeManager2 = new ThemeManager();

const unsubscribe = themeManager1.subscribe(theme => {
  console.log('Theme changed to:', theme);
});

themeManager2.setTheme('dark');
themeManager1.setTheme('light');

unsubscribe();
themeManager2.setTheme('dark');

// Practical example: API client singleton
class ApiClient {
  constructor() {
    if (ApiClient.instance) {
      return ApiClient.instance;
    }
    
    this.baseURL = 'https://api.example.com';
    this.headers = {
      'Content-Type': 'application/json'
    };
    ApiClient.instance = this;
  }
  
  setBaseURL(baseURL) {
    this.baseURL = baseURL;
  }
  
  setHeader(key, value) {
    this.headers[key] = value;
  }
  
  async get(endpoint) {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'GET',
      headers: this.headers
    });
    
    return response.json();
  }
  
  async post(endpoint, data) {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify(data)
    });
    
    return response.json();
  }
}

const api1 = new ApiClient();
const api2 = new ApiClient();

api1.setBaseURL('https://new-api.example.com');
api2.setHeader('Authorization', 'Bearer token');

api1.get('/users').then(users => {
  console.log('Users:', users);
});
Performance & Optimization

Debouncing and Throttling

Debouncing and throttling are two techniques to control how many times a function gets executed in response to events. They are particularly useful for optimizing performance when dealing with events that fire frequently, such as resize, scroll, or input events.

Debouncing:

  • Delays the execution of a function until a certain amount of time has passed without it being called
  • Useful for search inputs, auto-save features, and window resize events
  • Ensures the function only runs once after a series of rapid events

Throttling:

  • Limits the execution of a function to no more than once every specified time period
  • Useful for scroll events, button clicks, and animation frames
  • Ensures the function runs at a regular interval, not more frequently
// Debouncing implementation
function debounce(func, wait) {
  let timeout;
  
  return function(...args) {
    const context = this;
    
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
}

// Throttling implementation
function throttle(func, wait) {
  let inThrottle;
  
  return function(...args) {
    const context = this;
    
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      
      setTimeout(() => {
        inThrottle = false;
      }, wait);
    }
  };
}

// Example: search input with debouncing
const searchInput = document.getElementById('search');
const resultsContainer = document.getElementById('results');

function search(query) {
  console.log('Searching for:', query);
  
  fetch(`https://api.example.com/search?q=${query}`)
    .then(response => response.json())
    .then(results => {
      resultsContainer.innerHTML = results.map(result => 
        `<div>${result.title}</div>`
      ).join('');
    })
    .catch(error => {
      console.error('Search error:', error);
    });
}

const debouncedSearch = debounce(search, 300);

searchInput.addEventListener('input', (event) => {
  const query = event.target.value;
  
  if (query.trim()) {
    debouncedSearch(query);
  } else {
    resultsContainer.innerHTML = '';
  }
});

// Example: window resize with debouncing
function handleResize() {
  console.log('Window resized:', window.innerWidth, 'x', window.innerHeight);
}

const debouncedResize = debounce(handleResize, 200);

window.addEventListener('resize', debouncedResize);

// Example: scroll position with throttling
function updateScrollPosition() {
  const scrollPosition = window.pageYOffset;
  document.getElementById('scroll-indicator').textContent = `Scroll: ${scrollPosition}px`;
}

const throttledScroll = throttle(updateScrollPosition, 100);

window.addEventListener('scroll', throttledScroll);

// Example: button click with throttling
function handleClick() {
  console.log('Button clicked at:', new Date().toISOString());
}

const throttledClick = throttle(handleClick, 1000);

document.getElementById('throttled-button').addEventListener('click', throttledClick);

// Advanced debouncing with immediate option
function advancedDebounce(func, wait, immediate = false) {
  let timeout;
  
  return function(...args) {
    const context = this;
    const later = () => {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    
    if (callNow) func.apply(context, args);
  };
}

// Example: auto-save with immediate debouncing
function saveContent() {
  console.log('Saving content...');
}

const debouncedSave = advancedDebounce(saveContent, 1000, true);

document.getElementById('editor').addEventListener('input', debouncedSave);

// Advanced throttling with trailing option
function advancedThrottle(func, wait, options = {}) {
  const { leading = true, trailing = true } = options;
  let timeout, lastArgs, lastThis;
  let result;
  
  return function(...args) {
    const context = this;
    lastArgs = args;
    lastThis = context;
    
    if (!timeout) {
      if (leading) {
        result = func.apply(context, args);
      }
      
      timeout = setTimeout(() => {
        timeout = null;
        
        if (trailing && lastArgs) {
          result = func.apply(lastThis, lastArgs);
          lastArgs = lastThis = null;
        }
      }, wait);
    }
    
    return result;
  };
}

// Example: animation frame throttling
function animateElement() {
  const element = document.getElementById('animated');
  let position = 0;
  
  function updatePosition() {
    position += 5;
    element.style.transform = `translateX(${position}px)`;
    
    if (position < 200) {
      requestAnimationFrame(updatePosition);
    }
  }
  
  requestAnimationFrame(updatePosition);
}

// Practical example: infinite scroll with throttling
function loadMoreContent() {
  console.log('Loading more content...');
  
  fetch('https://jsonplaceholder.typicode.com/posts')
    .then(response => response.json())
    .then(posts => {
      const container = document.getElementById('content');
      
      posts.forEach(post => {
        const div = document.createElement('div');
        div.className = 'post';
        div.textContent = post.title;
        container.appendChild(div);
      });
    })
    .catch(error => {
      console.error('Error loading content:', error);
    });
}

const throttledLoadMore = throttle(loadMoreContent, 500);

window.addEventListener('scroll', () => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    throttledLoadMore();
  }
});

// Practical example: form validation with debouncing
function validateEmail(email) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

function validateForm() {
  const email = document.getElementById('email').value;
  const errorElement = document.getElementById('email-error');
  
  if (!validateEmail(email)) {
    errorElement.textContent = 'Please enter a valid email address';
    errorElement.style.display = 'block';
  } else {
    errorElement.style.display = 'none';
  }
}

const debouncedValidation = debounce(validateForm, 300);

document.getElementById('email').addEventListener('input', debouncedValidation);

// Practical example: performance monitoring with throttling
function logPerformance() {
  const memory = performance.memory;
  const timing = performance.timing;
  
  console.log({
    memory: {
      used: Math.round(memory.usedJSHeapSize / 1048576) + ' MB',
      total: Math.round(memory.totalJSHeapSize / 1048576) + ' MB'
    },
    timing: {
      loadTime: timing.loadEventEnd - timing.navigationStart,
      domReady: timing.domContentLoadedEventEnd - timing.navigationStart
    }
  });
}

const throttledLogPerformance = throttle(logPerformance, 5000);

setInterval(throttledLogPerformance, 5000);
Performance & Optimization

Memory Management in JavaScript

Memory management in JavaScript is primarily automatic, handled by the garbage collector. However, understanding how memory is allocated and freed can help you write more efficient code and avoid memory leaks.

Key concepts in JavaScript memory management:

  • Memory Life Cycle: Allocation, usage, and release
  • Garbage Collection: Automatic memory management
  • Memory Leaks: Unintentional retention of memory
  • References: Strong, weak, and circular references

Common causes of memory leaks:

  • Unintentional global variables
  • Closed-over variables in closures
  • Detached DOM elements
  • Forgotten timers or intervals
// Memory allocation examples
// Primitive values (stored directly)
let num = 42;
let str = 'Hello';
let bool = true;

// Objects (stored by reference)
let obj = { name: 'John' };
let arr = [1, 2, 3];
let func = function() { return 'Hello'; };

// Memory release examples
// When variables go out of scope, memory can be reclaimed
function createObject() {
  const localObj = { data: 'This will be garbage collected' };
  return localObj;
}

const obj = createObject();
// When obj is reassigned or goes out of scope, the original object can be garbage collected

// Common memory leak: global variables
function leakGlobal() {
  leakedVar = 'I am a global variable'; // Creates a global variable
}

leakGlobal();

// Common memory leak: closures
function createLeakyClosure() {
  const largeObject = new Array(1000000).fill('data');
  
  return function() {
    // This function keeps a reference to largeObject
    return largeObject[0];
  };
}

const leakyFunction = createLeakyClosure();
// largeObject cannot be garbage collected as long as leakyFunction exists

// Common memory leak: detached DOM elements
function createDetachedElement() {
  const div = document.createElement('div');
  div.textContent = 'I am detached';
  
  // div is created but never added to the DOM
  // If we keep a reference to it, it cannot be garbage collected
  return div;
}

const detachedElement = createDetachedElement();

// Common memory leak: forgotten timers
function startLeakyTimer() {
  const data = new Array(1000000).fill('data');
  
  setInterval(() => {
    console.log('Timer running');
    // This timer keeps a reference to data
  }, 1000);
}

// The timer and data will never be garbage collected

// Proper cleanup: clearing timers
function startProperTimer() {
  const data = new Array(1000000).fill('data');
  
  const intervalId = setInterval(() => {
    console.log('Timer running');
  }, 1000);
  
  return function cleanup() {
    clearInterval(intervalId);
    // Now data can be garbage collected
  };
}

const cleanup = startProperTimer();
// Later, when done:
cleanup();

// Using WeakMap and WeakSet to avoid memory leaks
const weakMap = new WeakMap();
const weakSet = new WeakSet();

function createUser(name) {
  const user = { name };
  weakMap.set(user, 'User data');
  weakSet.add(user);
  return user;
}

let user1 = createUser('John');
let user2 = createUser('Jane');

console.log(weakMap.get(user1)); // 'User data'
console.log(weakSet.has(user2)); // true

// When user1 is no longer referenced, it can be garbage collected
// and the associated data in weakMap can also be garbage collected
user1 = null;

// Memory monitoring
function checkMemoryUsage() {
  if (performance.memory) {
    const memory = performance.memory;
    console.log({
      used: Math.round(memory.usedJSHeapSize / 1048576) + ' MB',
      total: Math.round(memory.totalJSHeapSize / 1048576) + ' MB',
      limit: Math.round(memory.jsHeapSizeLimit / 1048576) + ' MB'
    });
  }
}

// Memory optimization: object pooling
class ObjectPool {
  constructor(createFn, resetFn, initialSize = 10) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
    
    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn());
    }
  }
  
  acquire() {
    if (this.pool.length > 0) {
      return this.pool.pop();
    }
    return this.createFn();
  }
  
  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

const vectorPool = new ObjectPool(
  () => ({ x: 0, y: 0, z: 0 }),
  (v) => { v.x = 0; v.y = 0; v.z = 0; },
  100
);

function processVectors() {
  const vectors = [];
  
  for (let i = 0; i < 1000; i++) {
    const v = vectorPool.acquire();
    v.x = Math.random();
    v.y = Math.random();
    v.z = Math.random();
    
    // Process vector...
    
    vectors.push(v);
  }
  
  // Return vectors to pool
  vectors.forEach(v => vectorPool.release(v));
}

// Memory optimization: avoiding unnecessary object creation
function badPerformance() {
  const result = [];
  
  for (let i = 0; i < 1000; i++) {
    // Creates a new object in each iteration
    result.push({ index: i, value: i * 2 });
  }
  
  return result;
}

function goodPerformance() {
  const result = [];
  
  for (let i = 0; i < 1000; i++) {
    // Reuses the same object
    const item = { index: i, value: i * 2 };
    result.push(item);
  }
  
  return result;
}

// Memory optimization: using requestAnimationFrame for animations
function animate() {
  const element = document.getElementById('animated');
  let position = 0;
  
  function update() {
    position += 1;
    element.style.transform = `translateX(${position}px)`;
    
    if (position < 100) {
      requestAnimationFrame(update);
    }
  }
  
  requestAnimationFrame(update);
}

// Memory optimization: cleaning up event listeners
function addEventListeners() {
  const button = document.getElementById('button');
  
  function handleClick() {
    console.log('Button clicked');
  }
  
  button.addEventListener('click', handleClick);
  
  return function cleanup() {
    button.removeEventListener('click', handleClick);
  };
}

const cleanup = addEventListeners();
// Later, when done:
cleanup();

// Memory optimization: using lazy loading
function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    
    img.onload = () => resolve(img);
    img.onerror = reject;
    
    img.src = src;
  });
}

function lazyLoadImages() {
  const images = document.querySelectorAll('img[data-src]');
  
  const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        const src = img.dataset.src;
        
        loadImage(src).then(loadedImg => {
          img.src = loadedImg.src;
          img.removeAttribute('data-src');
        });
        
        imageObserver.unobserve(img);
      }
    });
  });
  
  images.forEach(img => imageObserver.observe(img));
}

lazyLoadImages();