JavaScript NotesRohit’s interview study guide
Chapter 10

Design Patterns

Singleton, factory, observer, decorator (the pattern and the @ syntax), dependency injection and object pooling — in idiomatic JavaScript.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Singleton

A singleton guarantees there's only one instance of something — a DB connection, a config store, a logger — with one global access point. The problem it solves is coordination: if every part of the app opened its own database connection or kept its own copy of the config, you'd waste resources and the copies could disagree.

The mental model is a building with one reception desk. However many visitors arrive, and whichever door they come in by, they all end up talking to the same desk. In a class-based singleton, both doors — getInstance() and new — lead to the same object, which the class keeps in a private static field.

JavaScript
class Database {
  static #instance = null;

  constructor() {
    if (Database.#instance) {
      return Database.#instance; // `new` hands back the existing instance
    }
    this.connectedAt = Date.now();
    Database.#instance = this;
  }

  static getInstance() {
    return (Database.#instance ??= new Database());
  }

  query(sql) {
    return `running: ${sql}`;
  }
}

const a = Database.getInstance();
const b = new Database();
a === b; // true
What’s happening
  1. When the class is defined, the private static field Database.#instance is created once, on the class itself, with the value null. Because it's #private, no code outside the class body can read it or reset it.
  2. Database.getInstance() evaluates Database.#instance ??= new Database(). ??= only assigns when the left side is null or undefined — it is null, so new Database() runs.
  3. Inside that constructor Database.#instance is still null, so the if is skipped: it sets this.connectedAt and stores this in #instance. The ??= then assigns the same object again (harmless) and getInstance returns it. a is that object.
  4. new Database() runs the constructor a second time. new has already created a fresh empty object as this, but now #instance is set, so the constructor returns the existing instance. When a constructor explicitly returns an object, new uses that object instead of this — the fresh one is simply discarded. connectedAt is not overwritten.
  5. So a === b is true: same reference, one connection. Without the return in the constructor, new Database() would quietly build a second instance and the guarantee would only hold for callers who remembered to use getInstance().
AdvancedSingleton via an ES module export

ES modules are naturally singleton-ish

Since a module's code runs only once and is cached (see Modules are singletons), the simplest singleton in modern JS is just exporting an instance:

JavaScript
// logger.js
class Logger {
  #logs = [];
  log(msg) {
    this.#logs.push(msg);
    console.log(`[${new Date().toISOString()}] ${msg}`);
  }
  get count() {
    return this.#logs.length;
  }
}

export const logger = new Logger(); // every importer shares this one
What’s happening
  1. The first time any file does import { logger } from "./logger.js", the module loader fetches and evaluates logger.js once: the class is defined and new Logger() creates one object with an empty #logs array.
  2. The loader caches the evaluated module, keyed by its resolved URL (or file path in Node). Every later import of the same module gets the cached module — the body never runs again.
  3. So if a.js calls logger.log("hi") and b.js reads logger.count, b.js sees 1: both files hold a reference to the same object.
  4. You get the singleton guarantee without any static-field machinery — and the class itself isn't exported, so nobody else can make a second Logger.
  5. The guarantee is "one per resolved module", not "one per app". If two different versions of a package end up in node_modules, or the same file is loaded once as ESM and once as CommonJS, you get two module instances and therefore two loggers.

#Factory pattern

A factory is a function that creates objects for you, so callers don't need to know which class to instantiate or how to configure it. The caller says what it wants ("an SMS notifier"); the factory decides how to build it.

Think of a restaurant order: you ask for "the fish", not "a SeaBassFillet constructed with a 180°C oven". If the kitchen switches supplier, your order doesn't change. That decoupling is the whole point — the code that uses notifiers depends only on the shape they share (a send method), never on the concrete classes.

JavaScript
class EmailNotifier {
  constructor({ address }) {
    this.address = address;
  }
  send(msg) {
    return `Email to ${this.address}: ${msg}`;
  }
}
class SmsNotifier {
  constructor({ phone }) {
    this.phone = phone;
  }
  send(msg) {
    return `SMS to ${this.phone}: ${msg}`;
  }
}

const notifiers = {
  email: EmailNotifier,
  sms: SmsNotifier,
};

function createNotifier(type, options) {
  const Notifier = notifiers[type];
  if (!Notifier) throw new Error(`Unknown notifier type: ${type}`);
  return new Notifier(options);
}

createNotifier("sms", { phone: "+1 555 0100" }).send("Your code is 1234");
What’s happening
  1. Both classes have the same public shape — a constructor that takes an options object and a send(msg) method. That shared interface is what lets them be swapped freely.
  2. notifiers is a registry: a lookup table from a type name to a class. Classes are just values in JavaScript, so you can store them in an object and look them up by string.
  3. createNotifier("sms", { phone: "+1 555 0100" }) looks up notifiers["sms"], which is SmsNotifier, and stores it in the local Notifier. An unknown type like "fax" gives undefined, so the guard throws Unknown notifier type: fax instead of failing later with a confusing "not a constructor" error.
  4. new Notifier(options) runs SmsNotifier's constructor, which destructures phone from the options and stores it on this.
  5. .send("Your code is 1234") returns "SMS to +1 555 0100: Your code is 1234". The calling line never mentioned SmsNotifier — swap the string to "email" (with an address) and the same line sends an email.
AdvancedOpen/closed factories and factory functions

Adding a new type means registering it in the map — the calling code never changes (open/closed principle). Factory functions that return object literals (const createUser = (name) => ({ name, … })) are also very common in JS and avoid new and this entirely.

AdvancedPitfall: inherited keys like constructor

#Observer / pub-sub (EventEmitter)

Added

The observer pattern lets objects subscribe to events from another object without either knowing the details of the other. It's everywhere: DOM events, Node's EventEmitter, Redux store.subscribe, RxJS.

The problem it solves is fan-out without coupling. A shopping cart shouldn't need to know that a badge, an analytics tracker and a "free shipping" banner all care when an item is added. Instead, the cart publishes an "add" event and anyone interested subscribes. Think of a newsletter: the publisher keeps a mailing list and sends each issue to whoever is on it; readers sign up and unsubscribe without the publisher changing anything.

EventEmitter.js
class EventEmitter {
  #listeners = new Map(); // event name → Set of functions

  on(event, listener) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(listener);
    return () => this.off(event, listener); // return an unsubscribe function
  }

  off(event, listener) {
    this.#listeners.get(event)?.delete(listener);
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener(...args);
    };
    return this.on(event, wrapper);
  }

  emit(event, ...args) {
    // snapshot first, so listeners added or removed during emit don't change this round
    for (const listener of [...(this.#listeners.get(event) ?? [])]) {
      listener(...args);
    }
  }
}

const cart = new EventEmitter();
const unsubscribe = cart.on("add", (item) => console.log("Added", item));
cart.once("add", () => console.log("First item!"));

cart.emit("add", "Book"); // Added Book, First item!
cart.emit("add", "Pen");  // Added Pen
unsubscribe();
cart.emit("add", "Cup");  // (nothing)
What’s happening
  1. cart.on("add", L1) finds no entry for "add", creates an empty Set, and adds L1 (the "Added" logger). The listeners map is now "add" → {L1}. It returns an arrow function that closes over event and listener — calling it later runs off("add", L1). That's what unsubscribe holds.
  2. cart.once("add", L2) doesn't register L2 directly. It creates wrapper — a function that first removes itself and then calls L2 — and registers that: "add" → {L1, wrapper}. Because it returns this.on(event, wrapper), the unsubscribe it hands back removes the wrapper, so you can cancel a once before it ever fires.
  3. cart.emit("add", "Book") takes a snapshot array [L1, wrapper] and calls each with "Book". L1 logs Added Book. wrapper removes itself (the set becomes {L1}) and then calls L2, which logs First item!.
  4. cart.emit("add", "Pen") snapshots [L1], so only Added Pen is logged — the once listener is gone.
  5. unsubscribe() runs off("add", L1), leaving an empty set. cart.emit("add", "Cup") snapshots [] and nothing happens. Emitting an event nobody ever subscribed to also works: get returns undefined, ?? [] turns it into an empty list.
  6. Why the snapshot: a Set iterated live does visit items added during the loop. A listener that unsubscribes and re-subscribes itself (delete + add moves it to the end of the set) would then be called again and again, forever. Copying first means each emit calls exactly the listeners that were registered when it started — the same rule Node's EventEmitter and the DOM follow.
AdvancedSet listeners and unsubscribe functions

Two smaller design choices are worth being able to explain. Using a Set means registering the same function twice only stores it once (Node's EventEmitter allows duplicates and would call it twice). And returning an unsubscribe function from on — instead of making callers keep a reference and call off — is the style React effects, Redux and RxJS use, because it fits neatly into cleanup code: useEffect(() => cart.on("add", handler), []).

#Decorator pattern (higher-order wrappers)

The decorator pattern adds behaviour to a function or object by wrapping it, without modifying the original. In JavaScript that's basically a higher-order function.

Picture a gift being wrapped: the gift is unchanged, but each layer of paper adds something, and you can stack as many layers as you like. A decorator takes a function and returns a new function with the same call signature that does something extra before and/or after calling the original — logging, timing, caching, retrying, access checks. The original stays clean and testable, and the extra concern lives in one reusable place.

JavaScript
function withLogging(fn) {
  return function (...args) {
    console.log(`→ ${fn.name}(${args.join(", ")})`);
    const result = fn.apply(this, args);
    console.log(`← ${result}`);
    return result;
  };
}

function withTiming(fn) {
  return function (...args) {
    const start = performance.now();
    try {
      return fn.apply(this, args);
    } finally {
      console.log(`${fn.name} took ${(performance.now() - start).toFixed(1)}ms`);
    }
  };
}

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

const tracedAdd = withLogging(withTiming(add)); // decorators stack
tracedAdd(2, 3);
What’s happening
  1. Wrapping happens inside out: withTiming(add) runs first and returns a new anonymous function — call it timed — that closes over fn = add. Then withLogging(timed) returns another function, logged, that closes over fn = timed. tracedAdd is logged.
  2. tracedAdd(2, 3) runs the outer layer. It logs → (2, 3) — note the missing name: its fn is timed, an anonymous function expression whose name is "", not add. (See the gotcha below.)
  3. It then calls fn.apply(this, args), which runs timed with (2, 3). Using .apply(this, args) instead of fn(...args) forwards whatever this the wrapper was called with, so the decorator also works on methods like obj.method = withTiming(obj.method).
  4. timed records the start time and calls add(2, 3), which returns 5. The return inside try is held while finally runs and logs add took 0.0ms — this layer's fn really is add, so its name is correct. finally also runs if add throws, so failures get timed too.
  5. Control comes back to the outer layer with result = 5; it logs ← 5 and returns 5. Full output: → (2, 3), add took 0.0ms, ← 5.
  6. Order matters: withTiming(withLogging(add)) would time the logging as well, and the outer timing line would then be the one with the blank name.
AdvancedDecorators you already use

debounce, throttle, memoize, once and React's memo() / higher-order components are all decorators in this sense.

AdvancedPitfall: lost name and length

#Decorators syntax (@decorator)

The @decorator syntax applies a wrapper to a class or class member declaratively. It's a TC39 Stage 3 proposal — supported today by TypeScript 5+ and Babel, and arriving natively in engines (Node 26 still rejects it as a syntax error, so this example needs a compiler). A method decorator receives the original method and a context object, and returns a replacement.

It's the same wrapping idea as the previous topic, with two differences. First, it's declarative: you write @retry(3) next to the method instead of reassigning ApiClient.prototype.fetchData = retry(3)(…) after the class. Second, it runs once, when the class is defined, not on every call — the decorator's job is to hand back the function that will sit on the prototype from then on.

JavaScript
function logged(originalMethod, context) {
  const name = String(context.name);
  return function (...args) {
    console.log(`→ ${name}(${args.join(", ")})`);
    const result = originalMethod.call(this, ...args);
    console.log(`← ${name} returned`, result);
    return result;
  };
}

// A decorator factory: call it with options, it returns the decorator
function retry(times) {
  return function (originalMethod, context) {
    return async function (...args) {
      let lastError;
      for (let attempt = 1; attempt <= times; attempt++) {
        try {
          return await originalMethod.call(this, ...args);
        } catch (err) {
          lastError = err;
          console.warn(`${String(context.name)} failed (attempt ${attempt}/${times})`);
        }
      }
      throw lastError;
    };
  };
}

class ApiClient {
  @retry(3)
  async fetchData(url) {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }

  @logged
  add(a, b) {
    return a + b;
  }
}

await new ApiClient().fetchData("/api/flaky"); // retried up to 3 times
What’s happening
  1. When the class ApiClient declaration is evaluated, the expression after each @ is evaluated first. @retry(3) calls retry(3), which returns the real decorator (a factory); @logged is used as-is because logged already is a decorator.
  2. Each decorator is then called with the original method and a context object — for fetchData that includes kind: "method" and name: "fetchData". Whatever function the decorator returns replaces the method on ApiClient.prototype. This happens once, before any instance exists.
  3. new ApiClient().fetchData("/api/flaky") therefore runs the async wrapper from retry. On attempt 1 it calls the original with .call(this, ...args), so this is still the instance. Say the server returns 503: the original throws, the catch stores the error and warns fetchData failed (attempt 1/3).
  4. The loop tries again. If attempt 2 also fails you see (attempt 2/3); if attempt 3 succeeds, return await hands back the parsed JSON and the loop ends. If all three fail, the loop finishes and throw lastError rejects with the last HTTP 503 error.
  5. The await in return await originalMethod.call(…) is essential. Without it, the wrapper would return the pending promise straight out of the try; when that promise later rejects, the catch is no longer in play and the first failure escapes with no retry at all.
  6. @logged works the same way for the synchronous add: new ApiClient().add(2, 3) logs → add(2, 3) then ← add returned 5. Here the name is right because the decorator reads it from context.name rather than from the wrapped function.
AdvancedTypeScript legacy vs standard decorators

#Dependency injection

Dependency injection (DI) means a class receives the things it depends on instead of creating them itself. That makes it easy to swap implementations — most importantly, to use fakes in tests.

The mental model is a power socket. A lamp doesn't contain its own power station; it has a plug, and whoever installs it decides what it plugs into — mains, a battery pack, a test bench. In code, the "plug" is a constructor parameter: the class says "I need something with a create method and something with a send method", and the caller decides which concrete objects those are. The class depends on a shape, not on a specific implementation.

Without DI — hard-wired

JavaScript
class UserService {
  constructor() {
    this.repo = new PostgresUserRepo(); // fixed
    this.mailer = new SendGridMailer(); // fixed
  }
}
// Testing this hits a real DB and sends real email 😬

With DI — passed in

JavaScript
class UserService {
  constructor({ repo, mailer }) {
    this.repo = repo;
    this.mailer = mailer;
  }
  async register(data) {
    const user = await this.repo.create(data);
    await this.mailer.send(user.email, "Welcome!");
    return user;
  }
}
AdvancedWiring dependencies at the composition root

On the left, UserService itself decides to use Postgres and SendGrid; every new UserService() drags in a database connection and an email account, and the only way to test it is to mock modules. On the right, the constructor just stores whatever it's given — UserService no longer knows or cares which database or email provider exists.

JavaScript
// Production wiring (the "composition root")
const service = new UserService({
  repo: new PostgresUserRepo(db),
  mailer: new SendGridMailer(apiKey),
});

// Test wiring — no database, no network
const sent = [];
const testService = new UserService({
  repo: { create: async (d) => ({ id: 1, ...d }) },
  mailer: { send: async (to, msg) => sent.push({ to, msg }) },
});
await testService.register({ email: "a@b.com" });
console.log(sent); // [{ to: "a@b.com", msg: "Welcome!" }]
What’s happening
  1. The composition root is the one place (usually app start-up) where real implementations are created and plugged together. It's the only code that knows about Postgres and SendGrid.
  2. The test wiring passes two plain object literals instead. They aren't instances of any repo or mailer class — they just have the right method names. Duck typing is enough, because UserService only ever calls repo.create and mailer.send.
  3. testService.register({ email: "a@b.com" }) awaits repo.create(data). The fake returns { id: 1, email: "a@b.com" } — the spread copies the input and adds an id, the way a real insert would.
  4. It then awaits mailer.send("a@b.com", "Welcome!"). The fake pushes { to: "a@b.com", msg: "Welcome!" } into the sent array instead of sending anything.
  5. register returns the user, and the test can now assert on sent — proving the welcome email would have been sent, with no database, no network and no module mocking. That's the payoff of DI: the behaviour under test is real, only the edges are fake.
AdvancedDI containers in Angular, NestJS and React

Frameworks such as Angular and NestJS add a DI container that builds and injects dependencies automatically. React's Context is a form of DI for components.

#Object pooling

Added

From the very first line of my notes: "Look into object pooling."

Object pooling reuses a set of pre-created objects instead of constantly creating and discarding them. Creating thousands of short-lived objects per second (particles, bullets, vectors) makes the garbage collector run often, and GC pauses show up as dropped frames.

Think of a bowling alley's shoe rack. Instead of making new shoes for every bowler and throwing them away afterwards, the alley keeps a rack of pairs: you take a pair, use it, hand it back, and it's cleaned (reset) for the next person. Allocation happens up front, once; after that the steady state allocates nothing, so the GC has nothing new to collect.

ObjectPool.js
class ObjectPool {
  #free = [];

  constructor(create, reset, initialSize = 0) {
    this.create = create;
    this.reset = reset;
    for (let i = 0; i < initialSize; i++) this.#free.push(create());
  }

  acquire() {
    return this.#free.pop() ?? this.create(); // reuse if possible, otherwise make one
  }

  release(obj) {
    this.reset(obj);       // clean it so no old state leaks into the next user
    this.#free.push(obj);
  }

  get available() {
    return this.#free.length;
  }
}

const particles = new ObjectPool(
  () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0 }),
  (p) => Object.assign(p, { x: 0, y: 0, vx: 0, vy: 0, life: 0 }),
  500,
);

function spawn(x, y) {
  const p = particles.acquire();
  Object.assign(p, { x, y, vx: Math.random() - 0.5, vy: -1, life: 60 });
  return p;
}
// when p.life hits 0: particles.release(p)
What’s happening
  1. The pool is generic: it takes a create function (how to make a fresh object) and a reset function (how to wipe one clean). It knows nothing about particles.
  2. new ObjectPool(create, reset, 500) calls create() 500 times up front and stores the objects in the private #free array. particles.available is 500. All allocation happens here, typically during a loading screen.
  3. spawn(10, 20) calls acquire(). #free.pop() removes and returns the last free particle (O(1)), so available drops to 499. spawn then overwrites its fields with the new position, a random horizontal velocity and life: 60 frames.
  4. If 501 particles are alive at once, #free is empty, pop() returns undefined, and ?? this.create() makes a new one instead of failing. When it's released, the pool grows to 501 — this pool has no upper limit.
  5. When a particle's life reaches 0, the game calls particles.release(p). reset sets every field back to 0 and the object goes back on the #free stack, ready for the next spawn. Nothing became garbage, so the GC had no work to do.
  6. Why reset matters: if it forgot vx, the next particle would start with the previous one's velocity. Every field the users set must be wiped, or old state leaks between users.
AdvancedConnection pools and other resource pools

The same idea for expensive resources is everywhere in back-end code: database connection pools (pg.Pool), HTTP keep-alive agents and worker-thread pools. There the cost being saved isn't GC — it's the TCP handshake, TLS negotiation and authentication of a new connection, which can take tens of milliseconds each.

AdvancedPitfall: memory, resets, use-after-release

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.