Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A12
40 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA12 · Part 3 · Advanced

Build it yourself

The six things a senior loop asks you to build from scratch — because you can, not because you should ship them.

7 min read6 sections9 exercises
40 of 41

In this chapter

  • A minimal Promise, from scratch
  • Your own bind, call, and apply
  • debounce and throttle, from scratch
  • A deep clone that survives cycles
  • A deep equal

and 1 more

about 7 min
06On this page
  1. A minimal Promise, from scratch
  2. Your own bind, call, and apply
  3. debounce and throttle, from scratch
  4. A deep clone that survives cycles
  5. A deep equal
  6. An LRU cache with O(1) get and put

None of what follows should replace a library in production — reach for the real Promise, a maintained debounce, or structuredClone every time. Building each of these once earns something a library can't: the moment "it just works" turns into "I know exactly why it works," which is also, not coincidentally, the exact set of questions a senior interview loop reaches for.

A minimal Promise, from scratch

class MiniPromise {
  static PENDING = "pending";
  static FULFILLED = "fulfilled";
  static REJECTED = "rejected";

  #state = MiniPromise.PENDING;
  #value;
  #callbacks = [];

  constructor(executor) {
    const resolve = (value) => this.#settle(MiniPromise.FULFILLED, value);
    const reject = (reason) => this.#settle(MiniPromise.REJECTED, reason);
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  #settle(state, value) {
    if (this.#state !== MiniPromise.PENDING) return;   // a promise settles ONCE, ever
    this.#state = state;
    this.#value = value;
    queueMicrotask(() => this.#callbacks.forEach((cb) => cb()));
  }

  then(onFulfilled, onRejected) {
    return new MiniPromise((resolve, reject) => {
      const run = () => {
        try {
          if (this.#state === MiniPromise.FULFILLED) {
            resolve(onFulfilled ? onFulfilled(this.#value) : this.#value);
          } else if (onRejected) {
            resolve(onRejected(this.#value));
          } else {
            reject(this.#value);
          }
        } catch (err) {
          reject(err);
        }
      };
      if (this.#state === MiniPromise.PENDING) this.#callbacks.push(run);
      else queueMicrotask(run);
    });
  }

  catch(onRejected) {
    return this.then(undefined, onRejected);
  }
}

const p = new MiniPromise((resolve) => setTimeout(() => resolve(1), 10));
p.then((v) => v + 1).then((v) => console.log("got", v));   // what happens?

"got 2", after the 10ms timeout — a real, working .then() chain, including the part that trips most from-scratch attempts: .then() itself returns a new MiniPromise, so it can be chained again, and whatever the callback returns becomes that new promise's value. The #callbacks array exists specifically for a .then() attached before the promise has settled — exactly what happens above, since .then() runs synchronously while the executor is still waiting on its setTimeout.

⚠ What this leaves out A spec-compliant implementation (the Promises/A+ test suite) also handles a .then() callback returning another thenable — unwrapping it recursively instead of resolving with the promise itself — and guards against resolve being called with the promise's own instance. Both are a handful of extra lines, left out here to keep the core chaining mechanism readable.

Your own bind, call, and apply

Function.prototype.myCall = function (thisArg, ...args) {
  const fn = Symbol("fn");
  const context = thisArg ?? globalThis;
  context[fn] = this;                 // borrow the object's ability to invoke a method on it
  const result = context[fn](...args);
  delete context[fn];
  return result;
};

Function.prototype.myApply = function (thisArg, argsArray = []) {
  return this.myCall(thisArg, ...argsArray);
};

Function.prototype.myBind = function (thisArg, ...boundArgs) {
  const original = this;
  return function (...laterArgs) {
    return original.myCall(thisArg, ...boundArgs, ...laterArgs);
  };
};

function greet(greeting) { return greeting + ", " + this.name; }
console.log(greet.myCall({ name: "Ana" }, "Hi"));        // what happens?
console.log(greet.myBind({ name: "Ravi" })("Hello"));    // what happens?

"Hi, Ana", then "Hello, Ravi" — myCall's trick is the whole answer to "how do you set this without the real keyword": temporarily attach the function as a method on the target object (under a Symbol key so it can never collide with a real property), call it as obj.method() so the implicit binding rule kicks in naturally, then remove it again. myApply is myCall with an array instead of a spread list of arguments; myBind returns a brand-new function that always calls through myCall with a this that was locked in ahead of time.

⚠ What this leaves out Four gaps, exactly the ones a follow-up question would probe: a primitive thisArg (a string, a number) throws, because you can't set a property on one — real call autoboxes it into an object first. A frozen or sealed target throws for the same reason real call never does: it doesn't need to write a temporary property onto anything. If the called function throws, the temporary Symbol key is never cleaned up, because there's no finally around the call — a real, if obscure, leak. And myBind's result ignores new entirely: new (fn.myBind(obj))() still runs with this forced to obj, where real bind detects construction and lets new win, building a genuine instance instead.

debounce and throttle, from scratch

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);                                   // cancel the previous scheduled call...
    timer = setTimeout(() => fn.apply(this, args), wait);   // ...and schedule a fresh one
  };
}

function throttle(fn, wait) {
  let ready = true;
  return function (...args) {
    if (!ready) return;
    ready = false;
    fn.apply(this, args);
    setTimeout(() => { ready = true; }, wait);
  };
}

const log = debounce((label) => console.log(label), 100);
log("a"); log("b"); log("c");   // what happens?

Only "c" ever logs. Each call to the debounced function cancels the timer the previous call just set, so only the last call in a fast burst survives long enough to actually fire — the shape behind a search box that waits for typing to pause before it calls an API. throttle solves a different problem: it guarantees at most one call gets through per wait window, letting the first call in a burst through immediately and silently dropping the rest until the window resets — the shape behind a scroll handler that should react regularly, not on every single pixel of movement.

A deep clone that survives cycles

function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;   // primitives are already copies
  if (seen.has(value)) return seen.get(value);                      // already cloned — reuse it, don't loop forever

  const clone = Array.isArray(value) ? [] : {};
  seen.set(value, clone);
  for (const key of Object.keys(value)) {
    clone[key] = deepClone(value[key], seen);
  }
  return clone;
}

const original = { name: "Ana" };
original.self = original;             // a circular reference — points back at itself

const copy = deepClone(original);
console.log(copy.self === copy);      // what happens?
console.log(copy === original);       // what happens?

true, then false — the clone's circular reference points back at the clone, not the original, and it's a genuinely separate object. The seen WeakMap is what makes a circular structure survive at all: without it, deepClone(original.self) would call deepClone(original) again, which would clone .self again, forever. Recording each object's clone before recursing into its properties means a second visit to the same object returns the clone already in progress instead of starting over. structuredClone() does exactly this in native code — reach for it in real code; this version exists to show what it's actually doing.

A deep equal

function deepEqual(a, b) {
  if (Object.is(a, b)) return true;   // same reference, or same primitive — handles NaN and -0 correctly
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
}

console.log(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }));   // what happens?
console.log(deepEqual({ a: NaN }, { a: NaN }));                          // what happens?
console.log(deepEqual([1, 2], [1, 2]));                                  // what happens?

true, true, true — a plain a.b === b.b would have failed the very first test, since two different objects with the same shape are never ===. Object.is instead of === at the base case is what makes the second test pass — plain === would say NaN !== NaN and wrongly call two identical-looking objects unequal. Arrays fall out of the same code for free: Object.keys([1, 2]) returns ["0", "1"], so an array is just an object whose keys happen to be indices.

⚠ What this leaves out Comparing by own enumerable keys alone misses type entirely: two different Dates at the same instant, two Maps or Sets with identical contents, and [1, 2] against { 0: 1, 1: 2 } all come back true here, because none of those types expose their real data as an own enumerable key Object.keys can see. A complete version checks the constructor first, then compares .getTime() for dates and iterates entries for Map/Set — a real library (lodash's isEqual) is where that full case list actually lives.

An LRU cache with O(1) get and put

class LRUCache {
  #capacity;
  #map = new Map();               // Map remembers insertion order — this is the whole trick

  constructor(capacity) {
    this.#capacity = capacity;
  }

  get(key) {
    if (!this.#map.has(key)) return undefined;
    const value = this.#map.get(key);
    this.#map.delete(key);
    this.#map.set(key, value);    // re-insert — now it's the MOST recently used
    return value;
  }

  put(key, value) {
    this.#map.delete(key);
    this.#map.set(key, value);
    if (this.#map.size > this.#capacity) {
      this.#map.delete(this.#map.keys().next().value);   // evict the LEAST recently used — the first key
    }
  }
}

const cache = new LRUCache(2);
cache.put("a", 1);
cache.put("b", 2);
cache.get("a");          // touching "a" makes it recently used again
cache.put("c", 3);       // cache is full — evicts the least recently used

console.log(cache.get("a"), cache.get("b"), cache.get("c"));   // what happens?

1 undefined 3 — "b" is the one evicted, not "a", because reading "a" with get() re-inserted it, moving it to the end of the Map's iteration order. "b" was the least recently touched key once the cache hit capacity, so it's the one .keys().next().value — the map's current first key — throws out. Every operation here is a plain Map get/set/delete, each O(1), which is the entire reason an LRU cache is built on a Map instead of an array: no method here ever has to scan or shift anything.

Dry run: the LRU cache's internal order across the calls above

CallMap order afterReturn value
put("a", 1)[a]—
put("b", 2)[a, b]—
get("a")[b, a] — deleted and re-inserted, now most recent1
put("c", 3)[a, c] — size hit 3, evicted the first key ("b")—
get("a"), get("b"), get("c")—1, undefined, 3

"b" is the one evicted, not "a", purely because get("a") ran before put("c") and moved "a" to the end of the Map's iteration order. The cache never inspects a timestamp or a separate counter — insertion order in a plain Map is the entire mechanism.

Interview answer, one sentence

"These are the from-scratch asks interviews use to check you understand the mechanism: a promise is a state machine plus a callback queue, bind is a closure over this, debounce and throttle are closures over a timer, a deep clone needs a seen-map for cycles, and an LRU cache gets O(1) from a hash map plus a linked list."

Before you move on
  • Explain why .then() has to return a new MiniPromise instead of the same one, for chaining to work.
  • Trace why myBind ignores new, and what real bind does instead.
  • Explain the difference between what debounce and throttle each guarantee about a burst of calls.
  • Say why deepClone needs a WeakMap of seen objects, referencing the circular-reference example.
  • Walk through the LRU cache dry run and say why "b" gets evicted instead of "a".

09Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 9 testsWrite debounce from scratchadvanced
  • 10 testsBuild an LRU cacheadvanced
  • 9 testsA deep clone that survives cyclesadvanced
  • 10 testsA deep equaladvanced
  • 8 testsYour own bindadvanced
  • 8 teststhrottle, with a trailing calladvanced
  • 9 testsmemoize with a key functionadvanced
  • 11 testsmap, filter and reduce from scratchadvanced
  • 9 testscurry, with placeholdersadvanced
All 31 for this level →
A12

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A11Previous · A11JavaScript interview bank
  2. A12you are here
  3. Next · ★The cheat pageNot another lesson — the night-before-the-interview skim of the other 38.6 min read

01On this page

6 sections · 0%

  1. 01A minimal Promise, from scratch
  2. 02Your own bind, call, and apply
  3. 03debounce and throttle, from scratch
  4. 04A deep clone that survives cycles
  5. 05A deep equal
  6. 06An LRU cache with O(1) get and put

[] chapters · t top