Variables & Scoping
JavaScript has three declaration keywords, but modern code uses only two. Prefer const by default and let when reassignment is needed. var is legacy and should be avoided.
| Keyword | Scope | Reassign | Hoisting |
|---|---|---|---|
| const | Block | No | TDZ until declared |
| let | Block | Yes | TDZ until declared |
| var | Function | Yes | Hoisted, init undefined |
Hoisting moves declarations to the top of their scope during compilation. var declarations are hoisted and initialised to undefined; let and const are hoisted too but sit in the Temporal Dead Zone (TDZ) — accessing them before declaration throws a ReferenceError.
console.log(a); // undefined — var is hoisted & initialised
var a = 1;
console.log(b); // ReferenceError — b is in the TDZ
let b = 2;
// const requires initialisation and cannot be reassigned
const PI = 3.14159;
// PI = 3; // TypeError: Assignment to constant variable
// Note: const objects are still mutable
const cfg = { debug: true };
cfg.debug = false; // fine — the binding is constant, not the value
Closures
A closure is a function bundled with references to its surrounding lexical scope. The inner function keeps access to outer variables even after the outer function returns. Closures power data privacy, memoisation, and factory functions.
function makeCounter() {
let count = 0; // private state
return {
inc: () => ++count,
get: () => count,
};
}
const c = makeCounter();
c.inc(); c.inc();
c.get(); // 2 — count is not accessible from outside
// Classic loop bug: var shares one binding, let creates one per iteration
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)); // 0 1 2
Why it matters
Every React hook relies on closures — useState, useEffect, and event handlers close over props and state from the render they were created in. "Stale closure" bugs come from a handler capturing an old value.
Functions, Arrow Functions & this
Arrow functions do not bind their own this, arguments, or super. They inherit this lexically from where they are defined. Regular functions bind this based on how they are called.
const timer = {
seconds: 0,
startBroken() {
// regular function: `this` is undefined/window in the callback
setInterval(function () { this.seconds++; }, 1000);
},
startFixed() {
// arrow inherits `this` from startFixed -> the timer object
setInterval(() => { this.seconds++; }, 1000);
},
};
// Explicit binding
function greet(greeting) { return `${greeting}, ${this.name}`; }
greet.call({ name: 'Ada' }, 'Hi'); // "Hi, Ada"
greet.apply({ name: 'Ada' }, ['Hello']); // "Hello, Ada"
const bound = greet.bind({ name: 'Ada' });
bound('Hey'); // "Hey, Ada"
Destructuring & Spread
Destructuring pulls values out of arrays and objects into variables. The spread operator ... expands iterables; the same syntax in a parameter list or destructuring pattern is the rest operator, collecting the remainder.
// Object destructuring with defaults, renaming, and nesting
const user = { id: 1, name: 'Ada', address: { city: 'London' } };
const { name, role = 'member', address: { city } } = user;
// Array destructuring + swap
let [first, , third] = [10, 20, 30]; // first=10, third=30
let x = 1, y = 2;
[x, y] = [y, x]; // swap without temp
// Rest gathers the remainder
const [head, ...tail] = [1, 2, 3, 4]; // head=1, tail=[2,3,4]
const { id, ...rest } = user; // rest = everything but id
// Spread clones & merges (shallow)
const merged = { ...user, role: 'admin' };
const combined = [...[1, 2], ...[3, 4]]; // [1,2,3,4]
Array Methods
Prefer declarative iteration methods over manual loops. map transforms, filter selects, and reduce folds a collection into a single value. All return new arrays/values and do not mutate the source.
const orders = [
{ id: 1, total: 30, paid: true },
{ id: 2, total: 50, paid: false },
{ id: 3, total: 20, paid: true },
];
const ids = orders.map(o => o.id); // [1, 2, 3]
const paid = orders.filter(o => o.paid); // two orders
const revenue = orders
.filter(o => o.paid)
.reduce((sum, o) => sum + o.total, 0); // 50
// Other essentials
orders.find(o => o.total > 40); // first match or undefined
orders.some(o => !o.paid); // true if any match
orders.every(o => o.total > 0); // true if all match
orders.flatMap(o => [o.id, o.total]); // map then flatten one level
[3, 1, 2].toSorted((a, b) => a - b); // ES2023: non-mutating sort
Promises & async/await
A Promise represents a future value in one of three states: pending, fulfilled, or rejected. async/await is syntactic sugar over promises that lets you write asynchronous code in a synchronous style. Always wrap awaited calls in try/catch.
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Failed to load user', err);
throw err; // re-throw so callers can react
}
}
// Run independent work concurrently — do NOT await in a loop serially
const [user, posts] = await Promise.all([
loadUser(1),
fetch('/api/posts').then(r => r.json()),
]);
// Combinators
Promise.allSettled(tasks); // waits for all, never short-circuits
Promise.race(tasks); // first to settle (fulfil OR reject)
Promise.any(tasks); // first to fulfil, ignores rejections
Common trap
await inside a forEach does not wait — forEach ignores returned promises. Use a for...of loop for sequential work, or Promise.all(arr.map(...)) for concurrent work.
The Event Loop
JavaScript is single-threaded. The event loop runs synchronous code on the call stack, then drains the microtask queue (promise callbacks, queueMicrotask) completely, then processes one macrotask (timers, I/O, events) — repeating this cycle. Microtasks always run before the next macrotask.
console.log('1: sync');
setTimeout(() => console.log('4: timeout (macrotask)'), 0);
Promise.resolve().then(() => console.log('3: promise (microtask)'));
console.log('2: sync');
// Output order: 1, 2, 3, 4
ES Modules
ES Modules (ESM) are the standard. Each file is its own scope; you explicitly export and import bindings. Imports are hoisted and live (they reflect later changes to the exported binding).
// math.js — named + default exports
export const add = (a, b) => a + b;
export function mul(a, b) { return a * b; }
export default function sub(a, b) { return a - b; }
// app.js — importing
import sub, { add, mul } from './math.js';
import * as math from './math.js'; // namespace import
import { add as sum } from './math.js'; // rename
// Dynamic import returns a promise — great for code splitting
const { default: heavy } = await import('./heavy.js');
TypeScript: Types, Interfaces & Unions
TypeScript adds a static type layer that is erased at compile time. Use interface for object shapes you may extend or that model an API; use type for unions, tuples, and computed types.
| Feature | interface | type |
|---|---|---|
| Object shapes | Yes | Yes |
| Unions / tuples | No | Yes |
| Declaration merging | Yes | No |
| extends / & intersect | extends | & intersection |
interface User {
id: number;
name: string;
role?: 'admin' | 'member'; // optional + literal union
}
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: string }; // discriminated union
// Literal & union types
type Status = 'idle' | 'loading' | 'success' | 'error';
Generics
Generics let you write reusable, type-safe code parameterised over types — Array<T> is itself a generic. Use constraints with extends to require capabilities.
function first<T>(arr: Array<T>): T | undefined {
return arr[0];
}
const n = first<number>([1, 2, 3]); // number | undefined
// Constrained generic — T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
Utility Types
Built-in utility types transform existing types instead of duplicating them.
interface Todo { id: number; title: string; done: boolean; }
type TodoDraft = Partial<Todo>; // all props optional
type TodoKeys = Pick<Todo, 'id' | 'title'>; // subset of props
type TodoNoId = Omit<Todo, 'id'>; // remove props
type ReadTodo = Readonly<Todo>; // all props readonly
type StatusMap = Record<string, boolean>; // key/value map
type Required2 = Required<TodoDraft>; // all props required
Narrowing, Enums & Strict Mode
Narrowing uses control-flow analysis to refine a broad type to a specific one via typeof, in, instanceof, or discriminant checks. Prefer union literals over enum in modern code; use const enum or as const objects when you need runtime values.
function area(shape: Result<number>) {
if (shape.ok) return shape.data; // narrowed to the ok branch
return shape.error; // narrowed to the error branch
}
function format(x: string | number) {
if (typeof x === 'string') return x.trim(); // x is string here
return x.toFixed(2); // x is number here
}
// Modern alternative to enums
const Direction = { Up: 'UP', Down: 'DOWN' } as const;
type Direction = typeof Direction[keyof typeof Direction]; // 'UP' | 'DOWN'
Always enable strict mode
Set "strict": true in tsconfig.json. It turns on strictNullChecks, noImplicitAny, and more — catching null bugs and untyped values at compile time. Add noUncheckedIndexedAccess for even safer array/object access.
Practice Exercises
- Predict the output of a loop using
varvsletwithsetTimeout, then explain why using closures. - Write a
memoize(fn)higher-order function that caches results using a closure and a Map. - Implement
groupBy(arr, keyFn)usingreduce, and type it generically in TypeScript. - Order the log output of a snippet mixing synchronous code,
setTimeout(0), andPromise.resolve().then. - Define a discriminated union
Result<T>and a function that exhaustively narrows it, using anevercheck in the default branch. - Given an
interface User, derive a "create payload" type that omits the server-generatedidand makes timestamps optional usingOmitandPartial.