A12 · 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.
06On this page
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
| Call | Map order after | Return value |
|---|---|---|
| put("a", 1) | [a] | — |
| put("b", 2) | [a, b] | — |
| get("a") | [b, a] — deleted and re-inserted, now most recent | 1 |
| 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 newMiniPromiseinstead of the same one, for chaining to work. - Trace why
myBindignoresnew, and what realbinddoes instead. - Explain the difference between what
debounceandthrottleeach guarantee about a burst of calls. - Say why
deepCloneneeds aWeakMapof seen objects, referencing the circular-reference example. - Walk through the LRU cache dry run and say why "b" gets evicted instead of "a".
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series