JavaScript NotesRohit’s interview study guide
Chapter 11

Coding Challenges

"Implement it yourself" questions with clean solutions: polyfills, caches, data structures and string utilities. Debounce and throttle are in Browser, DOM & Events; curry and compose are in Functions.

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

#Polyfill: Array.prototype.map

My "head teaser" from the notes. The details interviewers look for: respect thisArg, pass (value, index, array), and skip holes in sparse arrays.

A polyfill is your own implementation of a built-in, written so older environments get the feature (core-js is a library of them). In an interview it's a test of whether you know the built-in's contract rather than just its happy path: what this is inside a method on Array.prototype, which arguments the callback receives, what happens with an optional second argument, and how gaps in an array are treated. The mental model: map is a loop that builds a new array of the same length, slot by slot, where each slot holds whatever the callback returned for the matching input slot.

myMap.js
Array.prototype.myMap = function (callbackFn, thisArg) {
  if (typeof callbackFn !== "function") {
    throw new TypeError(`${callbackFn} is not a function`);
  }

  const len = this.length;
  const result = new Array(len);

  for (let k = 0; k < len; k++) {
    // Skip holes in sparse arrays, like the real map does
    if (Object.hasOwn(this, k)) {
      result[k] = callbackFn.call(thisArg, this[k], k, this);
    }
  }
  return result;
};

[1, 2, , 4].myMap((i) => i * i); // [1, 4, <empty>, 16]
What’s happening
  1. Adding the function to Array.prototype makes it available on every array. When you call [1, 2, , 4].myMap(fn), this inside it is that array — which only works because it's a regular function.
  2. The type check runs first, so [1].myMap(5) throws TypeError: 5 is not a function before doing any work, just like the built-in.
  3. len is read once, up front — 4 here. If the callback pushes onto the array, the new elements aren't visited, matching the real map. new Array(4) creates a result with length 4 but no elements yet: four holes.
  4. The loop runs k = 0…3. Object.hasOwn(this, k) asks "does this array actually have an element at index k?". Index 2 was written as , , — a hole, not undefined — so the check is false and result[2] is left as a hole.
  5. For the other indexes it calls callbackFn.call(thisArg, this[k], k, this): value, index and the whole array, with this inside the callback set to thisArg. result fills in as [1], [1, 4], (skip), [1, 4, <empty>, 16].
  6. It returns a new array; the original is untouched. Because it only uses this.length and indexes, it also works on array-likes: Array.prototype.myMap.call({ length: 2, 0: "a", 1: "b" }, (s) => s.toUpperCase()) gives ["A", "B"].
AdvancedComplexity and enumerable polyfills

O(n) time and O(n) extra space for the result. Bonus point to raise yourself: assigning to Array.prototype creates an enumerable property, so for (const k in [7]) now yields "0" and "myMap". Real polyfills install methods with Object.defineProperty(Array.prototype, "myMap", { value: …, writable: true, configurable: true }), which is non-enumerable by default.

AdvancedSupporting thisArg in myMap
AdvancedWhy the polyfill needs a regular function

#Polyfill: filter and reduce

Added

Same family, different shapes of output. filter builds a shorter array containing only the elements the callback approved. reduce boils the whole array down to one value by carrying an accumulator from step to step: each call receives the result of the previous call. The tricky part of reduce is the start: with an initial value the accumulator starts there; without one, the first real element becomes the accumulator and the loop starts from the element after it.

JavaScript
Array.prototype.myFilter = function (callbackFn, thisArg) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    if (Object.hasOwn(this, i) && callbackFn.call(thisArg, this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

Array.prototype.myReduce = function (callbackFn, ...initial) {
  let i = 0;
  let acc;

  if (initial.length) {
    acc = initial[0];
  } else {
    while (i < this.length && !Object.hasOwn(this, i)) i++; // first real element
    if (i >= this.length) throw new TypeError("Reduce of empty array with no initial value");
    acc = this[i++];
  }

  for (; i < this.length; i++) {
    if (Object.hasOwn(this, i)) acc = callbackFn(acc, this[i], i, this);
  }
  return acc;
};

[1, 2, 3, 4].myFilter((n) => n % 2 === 0);  // [2, 4]
[1, 2, 3, 4].myReduce((a, b) => a + b);     // 10
[].myReduce((a, b) => a + b, 0);            // 0
What’s happening
  1. myFilter starts with an empty result and uses push, because it can't know the output length in advance. Object.hasOwn(this, i) && … skips holes before calling the callback, thanks to && short-circuiting. For [1, 2, 3, 4] with n % 2 === 0 the callback returns false, true, false, true, so result goes [] → [2] → [2, 4]. It pushes the original element, not the callback's return value.
  2. myReduce collects any extra arguments into the initial array. [1, 2, 3, 4].myReduce(add) has no second argument, so initial is [] and we take the else branch.
  3. The while skips leading holes (none here), acc = this[i++] sets acc = 1 and moves i to 1. If the array had no real elements at all, i would reach the length and we throw the same TypeError the built-in throws.
  4. The main loop continues from i = 1: acc goes 1 → 3 → 6 → 10, one callback per element, and 10 is returned. Note the callback gets (acc, value, index, array) — four arguments, accumulator first.
  5. [].myReduce(add, 0) takes the if branch: acc = 0, the loop runs zero times, and 0 is returned without ever calling the callback. That's why you should always pass an initial value when the array might be empty.
AdvancedHandling a missing initial value

...initial (instead of initial = undefined) lets us tell "no initial value" apart from an explicit undefined. With a default parameter, arr.myReduce(fn) and arr.myReduce(fn, undefined) would look identical; with rest parameters the first gives initial.length === 0 and the second initial.length === 1. The real reduce makes the same distinction: [1, 2].reduce((a, b) => a + b, undefined) starts from undefined and returns NaN.

One gap worth knowing: myFilter re-reads this.length on every iteration, whereas the real filter fixes the length at the start (like myMap does). A callback that pushes onto the array makes myFilter loop forever; [1, 2, 3].filter((v, i, a) => (a.push(v), true)) stops after three. Both are O(n) time.

#compose with a loop

The loop version from my notes, tidied up. (The one-liner with reduceRight is in Function composition.)

Composition builds one function out of several, where the output of each becomes the input of the next — compose(f, g, h)(x) means f(g(h(x))). The functions are listed in the order you'd write them nested, so they run right to left: the last one listed runs first. It's the maths notation f ∘ g, and it's how middleware chains and Redux's compose are built.

compose.js
export default function compose(...fns) {
  return function (value) {
    let result = value;
    for (let i = fns.length - 1; i >= 0; i--) {
      result = fns[i](result);
    }
    return result;
  };
}

const add1 = (num) => num + 1;
const double = (num) => num * 2;
const subtract10 = (num) => num - 10;

const composedFn = compose(subtract10, double, add1);
composedFn(3); // (3 + 1) * 2 - 10 = -2
What’s happening
  1. compose(subtract10, double, add1) doesn't run anything. It collects the three functions into fns and returns a new function that closes over fns.
  2. composedFn(3) starts with result = 3 — a fresh local variable for this call.
  3. The loop walks fns backwards, i = 2, 1, 0: add1(3) gives 4, then double(4) gives 8, then subtract10(8) gives -2.
  4. Each step overwrites result with the previous step's output, which is the "pipe the value through" behaviour. -2 is returned.
  5. With no functions, compose()(7) skips the loop and returns 7 — the identity function, which is the sensible "nothing to apply" answer.
AdvancedCorrecting my notes: shared result

#Polyfill: Promise.all

Added

Promise.all takes many promises and gives back one promise that fulfils with an array of all their results — or rejects as soon as any one of them rejects. It exists so you can start independent async jobs in parallel and wait for the whole batch, instead of awaiting them one after another.

The mental model is a checklist with a counter. You write one slot per job, in the order you were given them, and set a counter to the number of jobs. Whenever a job finishes you fill in its slot (not the next free one) and count down. When the counter hits zero, every slot is filled and you hand over the list. If any job fails, you give up immediately and report that failure.

promiseAll.js
function promiseAll(iterable) {
  return new Promise((resolve, reject) => {
    const items = Array.from(iterable);
    const results = new Array(items.length);
    let remaining = items.length;

    if (remaining === 0) return resolve([]);

    items.forEach((item, index) => {
      // Promise.resolve handles plain values and thenables too
      Promise.resolve(item).then((value) => {
        results[index] = value;          // keep the original order
        if (--remaining === 0) resolve(results);
      }, reject);                        // first rejection wins
    });
  });
}

await promiseAll([1, Promise.resolve(2), new Promise((r) => setTimeout(() => r(3), 100))]);
// [1, 2, 3]
What’s happening
  1. promiseAll immediately returns a new outer promise; the executor function runs synchronously. Array.from(iterable) turns any iterable (an array, a Set, a generator) into an array so we can count it and index it. Here items has 3 entries, results is [<3 empty>] and remaining = 3.
  2. An empty input would never trigger a .then callback, so it would hang forever without the remaining === 0 check, which resolves with [] straight away.
  3. forEach subscribes to every item synchronously, in one pass — all three are now running in parallel. Promise.resolve(item) wraps plain values (1) into an already-fulfilled promise, returns real promises unchanged, and adopts thenables, so the rest of the code can treat everything as a promise.
  4. The .then callbacks run later as microtasks. Item 0 (1) settles first: results[0] = 1, remaining goes 3 → 2. Item 1 (2) settles next: results[1] = 2, remaining 2 → 1. Neither reaches zero, so the outer promise stays pending.
  5. 100ms later the timer resolves item 2: results[2] = 3, remaining 1 → 0, and resolve([1, 2, 3]) fulfils the outer promise. Each callback writes to its own index (captured per item by the forEach callback), so order is input order even when finish order differs: promiseAll([slow, "fast"]) fills index 1 first but still gives ["slow", "fast"].
  6. reject is passed directly as the rejection handler, so the first item to reject rejects the outer promise. The other items keep running and may still call resolve later, but a promise can only settle once, so those calls are ignored.
AdvancedKey points for Promise.all

Key points: results stay in input order (not finish order), non-promise values are allowed, an empty input resolves to [] immediately, and the first rejection rejects the whole thing.

O(n) to subscribe; the total wait is as long as the slowest item, not the sum. What an interviewer is checking: that you count with a separate remaining variable rather than results.length (it's pre-sized, so it's always 3) or "is this the last index?" (index 2 can finish before index 0); that you write by index instead of push (which would give finish order); and that you handle the empty and non-promise cases.

#Memoize

Added

Cache a pure function's results by its arguments.

Memoization trades memory for time: the first call with a given set of arguments does the real work and stores the answer; every later call with the same arguments returns the stored answer without running the function. It only makes sense when the function is pure — the same inputs always produce the same output — because otherwise the cached answer could be stale. The mental model is a notebook beside a slow calculator: before you calculate, check the notebook; after you calculate, write the answer down.

memoize.js
function memoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  const cache = new Map();
  return function (...args) {
    const key = keyFn(...args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const slowSquare = (n) => {
  for (let i = 0; i < 1e8; i++); // pretend this is expensive
  return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(9); // slow the first time
fastSquare(9); // instant

// Recursive memoization: make the recursive calls go through the memoized version
const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
fib(50); // 12586269025, instantly (the naive version makes ~40 billion calls)
What’s happening
  1. memoize(slowSquare) creates one cache (a Map) and returns a wrapper that closes over it, so the cache survives between calls and is private to this memoized function.
  2. fastSquare(9) builds the key JSON.stringify([9]), which is the string "[9]". A string key is needed because two separate [9] arrays are different objects and would never match as Map keys. The cache is empty, so it runs slowSquare(9) (tens of milliseconds for the empty loop), stores "[9]" → 81 and returns 81.
  3. The second fastSquare(9) builds the same "[9]", cache.has is true, and 81 comes back in a few microseconds without calling slowSquare. cache.has is used rather than checking cache.get(key) !== undefined so that a function that legitimately returns undefined is cached too.
  4. For fib, the arrow function refers to fib — the memoized wrapper, not itself. So fib(50) calls fib(49) and fib(48) through the cache. The first branch down computes fib(49), fib(48) … fib(0) once each and stores them; every other call is a cache hit. In total the inner function runs 51 times and the cache ends up with 51 entries.
  5. Wrapping an existing recursive function doesn't get this benefit: memoize(slowFib) only caches the outer call, because slowFib's recursive calls go straight to slowFib. For slowFib(20) that's still 21,891 real calls the first time.
  6. fn.apply(this, args) forwards this, so memoizing a method works. But the key ignores this, so a.method(1) and b.method(1) would share one cache entry — fine for pure functions, wrong if the result depends on the instance.
AdvancedMemoize cost and complexity

Cost: a cache hit is O(1) plus the cost of building the key, which for JSON.stringify is proportional to the size of the arguments; space grows with every distinct argument list. What an interviewer is checking: the closure over a private cache, forwarding this and all arguments, a sensible key, and that you know the recursive version must call the memoized function.

AdvancedOnly memoize pure functions

#In-memory cache with a TTL

TTL means "time to live": each entry is only trusted for a fixed time after it was written, after which it's treated as gone. It's the standard way to cache data that changes upstream (an API response, a user profile) when you can accept it being a little stale but not very stale. Each entry carries its own expiry time, like a "best before" date on food. The only design question is when to throw away expired entries: as soon as they expire (eagerly, with a timer) or when someone next looks at them (lazily).

From my notes: a cache where entries expire after ttl milliseconds. Version 1 uses a timer per key to delete expired entries eagerly:

InMemoryCache.js
class InMemoryCache {
  constructor(ttl = 20_000) {
    this.ttl = ttl;
    this.cache = {};
  }

  set(key, value) {
    if (this.cache[key]) {
      clearTimeout(this.cache[key].timeoutId); // replacing a key resets its timer
    }

    const timeoutId = setTimeout(() => {
      delete this.cache[key];
    }, this.ttl);

    this.cache[key] = {
      value,
      expiresAt: Date.now() + this.ttl,
      timeoutId,
    };
  }

  get(key) {
    const entry = this.cache[key];
    if (!entry) return null;
    if (entry.expiresAt > Date.now()) return entry.value;
    delete this.cache[key];
    return null;
  }
}

module.exports = InMemoryCache;
What’s happening
  1. Each entry is stored as { value, expiresAt, timeoutId }. expiresAt is an absolute timestamp, so checking expiry later is a single comparison with Date.now().
  2. Trace with new InMemoryCache(100). At t=0, set("user", "Ada") schedules a timer for t=100 and stores expiresAt = 100. get("user") at once returns "Ada".
  3. At t=60, set("user", "Grace") finds the existing entry and clearTimeouts the old timer. Without that line the old timer would still fire at t=100 and delete the new value 40ms early. A fresh timer is set for t=160 and expiresAt becomes 160.
  4. At t=130, get("user") returns "Grace". At about t=160 the timer fires and deletes the key, so get("user") at t=200 finds nothing and returns null.
  5. Why get still checks expiresAt when a timer exists: timers are macrotasks and can fire late if the event loop is busy. If synchronous code blocks for 40ms on a 20ms TTL, a get in that code runs before the timer has had a chance to fire — the check returns null instead of a stale value and deletes the entry itself.
  6. Because this.cache is a plain object, every key is converted to a string: set(1, …) is readable as get("1"), and every object key becomes "[object Object]", so two different objects overwrite each other. Keys like "constructor" find inherited properties, which only works out here by luck (they have no expiresAt, so get returns null).

#Lazy-expiry cache (better approach and trade-offs)

The insight is that an expired entry only does harm if someone reads it. So instead of paying for a timer per key to delete entries on time, you store the expiry time and check it at the moment of reading. Expired entries are just ignored and cleaned up when touched. The cost moves from "every write" (a timer) to "every read" (one comparison), which is cheaper — at the price of memory for entries that expire and are never read again.

Version 2 drops the timers and checks expiry lazily, when an entry is read:

Cache.js
class Cache {
  constructor(ttl = 20_000) {
    this.ttl = ttl;
    this.cache = new Map();
  }

  set(key, value) {
    this.cache.set(key, { value, expiresAt: Date.now() + this.ttl });
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() >= entry.expiresAt) {
      this.cache.delete(key);
      return undefined;
    }
    return entry.value;
  }
}
What’s happening
  1. set stores { value, expiresAt } in a Map and does nothing else — no timer, nothing scheduled. Setting the same key again simply overwrites the entry with a new expiry, so there's nothing to clear.
  2. With the default 20s TTL: set("rate", 1.35) at t=0 stores expiresAt = 20000. get("rate") at t=5s finds the entry, 5000 >= 20000 is false, and 1.35 is returned.
  3. get("rate") at t=25s finds the entry, 25000 >= 20000 is true, so it deletes the key and returns undefined, exactly as if it had never been set. The next read finds no entry at all.
  4. If nobody ever reads "rate" again, the entry stays in the Map forever — that's the cost of lazy expiry, and why the "best of both" note below adds a sweep and a size limit.
  5. Using a Map fixes version 1's key problems: 1 and "1" are different keys, object keys work by identity, and there are no inherited keys like "constructor".
AdvancedTimer per key vs lazy expiry
Timer per key (v1)Lazy expiry (v2)
Memory freedImmediately at expiryOnly when the key is read again
CostOne timer per key — thousands of timers is expensiveNothing extra
Keeps a Node process aliveYes, unless you call timer.unref()No
StorageObject — keys become strings, prototype keys like "constructor" can clashMap — any key type, no clashes
TestabilityNeeds fake timersJust mock Date.now()

Best of both: lazy expiry plus an occasional sweep (setInterval(() => …, 60_000).unref()) that deletes expired entries in bulk, and a max size so the cache can't grow without limit — which leads to the LRU cache.

#LRU cache

Added

A Least Recently Used cache holds at most capacity items and evicts the one unused for the longest. A favourite interview question — and Map's insertion order makes it short: re-inserting a key moves it to the end, so the first key is always the least recently used.

The idea behind LRU is that recent use predicts future use: the item you touched a second ago is more likely to be needed again than one nobody has asked for in an hour. Think of a short stack of papers on your desk: whenever you use one, you put it back on top; when the stack is too tall, you throw away the one at the bottom. Here the Map is that stack, kept in order from least recently used (first) to most recently used (last).

LRUCache.js
class LRUCache {
  #map = new Map();

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

  get(key) {
    if (!this.#map.has(key)) return -1;
    const value = this.#map.get(key);
    this.#map.delete(key);   // move to the "most recent" end
    this.#map.set(key, value);
    return value;
  }

  put(key, value) {
    this.#map.delete(key);
    this.#map.set(key, value);
    if (this.#map.size > this.capacity) {
      const oldestKey = this.#map.keys().next().value;
      this.#map.delete(oldestKey);
    }
  }
}

const lru = new LRUCache(2);
lru.put("a", 1);
lru.put("b", 2);
lru.get("a");    // 1 — "a" is now most recent
lru.put("c", 3); // evicts "b"
lru.get("b");    // -1
What’s happening
  1. put("a", 1): delete("a") does nothing (it isn't there), set adds it. Key order: [a]. Size 1 is within capacity 2.
  2. put("b", 2) → key order [a, b]. Size 2, still within capacity. "a" is now the least recently used.
  3. get("a") finds the key, reads 1, then deletes and re-sets it. A plain set on an existing key keeps its old position, so the delete is what moves it to the end. Key order: [b, a]. Returns 1.
  4. put("c", 3) → order [b, a, c] and size 3 exceeds capacity. this.#map.keys() is an iterator in insertion order, so .next().value is the first key, "b", which is deleted. Order: [a, c]. Thanks to the get, "a" survived even though it was inserted before "b".
  5. get("b") returns -1 (the LeetCode convention for "missing") and changes nothing. A put("a", 10) now would also move "a" to the end via the same delete-then-set: order [c, a].
AdvancedLRU complexity and common bugs

All operations are O(1). In languages without an ordered hash map, you'd combine a hash map with a doubly linked list.

What an interviewer is checking: that get counts as a use and updates recency (the most common bug is only reordering on put), that put on an existing key updates both value and recency, and that you evict only when the size goes over capacity. Expect the follow-up "now do it without Map's ordering": the hash map gives O(1) lookup of a node, and the doubly linked list gives O(1) "unlink this node and move it to the head" plus O(1) "remove the tail".

#Implementing a stack

A stack is last-in, first-out (LIFO): push adds to the top, pop removes from the top, peek looks at the top without removing it.

Think of a stack of plates: you can only add to or take from the top. That restriction is the point — it's exactly the order you need whenever the most recent thing you started must be finished first: function calls (the call stack), undo history, matching brackets, depth-first search. A JavaScript array already has push and pop at the end; wrapping it in a class hides the other array methods so nobody can reach into the middle.

Stack.js
class Stack {
  #items = [];

  push(item) {
    this.#items.push(item);
    return this;
  }

  pop() {
    if (this.isEmpty()) throw new Error("Stack underflow");
    return this.#items.pop();
  }

  peek() {
    return this.#items.at(-1); // undefined if empty
  }

  isEmpty() {
    return this.#items.length === 0;
  }

  get size() {
    return this.#items.length;
  }
}

const s = new Stack().push(1).push(2).push(3);
s.peek();    // 3
s.pop();     // 3
s.size;      // 2
s.isEmpty(); // false
What’s happening
  1. #items is a private array whose end is the top of the stack. push and pop at the end of an array are O(1); working at the front with unshift/shift would be O(n), because every element would move.
  2. push returns this, so calls chain: new Stack().push(1).push(2).push(3) leaves #items as [1, 2, 3], with 3 on top.
  3. peek() uses at(-1), which counts from the end, and returns 3 without removing it.
  4. pop() checks for an empty stack first and throws "Stack underflow"; otherwise it removes and returns 3, leaving [1, 2]. Throwing makes the bug loud — the built-in [].pop() just returns undefined, which hides the mistake and is ambiguous if you ever store undefined.
  5. size is a getter, so it reads like a property (s.size, no parentheses) and is always in sync: 2. isEmpty() is false.
AdvancedBalanced brackets with a stack

Classic stack problem — balanced brackets:

JavaScript
function isBalanced(str) {
  const pairs = { ")": "(", "]": "[", "}": "{" };
  const stack = [];
  for (const ch of str) {
    if ("([{".includes(ch)) stack.push(ch);
    else if (ch in pairs && stack.pop() !== pairs[ch]) return false;
  }
  return stack.length === 0;
}

isBalanced("{[()]}"); // true
isBalanced("([)]");   // false
What’s happening
  1. pairs maps each closing bracket to the opener it must match. The stack holds the openers that are still waiting for their closer, most recent on top.
  2. "{[()]}": {, [ and ( are openers and get pushed — the stack is ["{", "[", "("]. Then ) pops "(", which equals pairs[")"], so the stack is ["{", "["]. ] pops "[" and } pops "{", leaving [].
  3. At the end stack.length === 0, so every opener was closed in the right order → true.
  4. "([)]": push (, push [ — stack ["(", "["]. Then ) pops "[", but pairs[")"] is "(". They differ, so it returns false immediately: the most recent unclosed bracket was [, and ) can't close it.
  5. Edge cases: "((" never pops, so the stack ends as ["(", "("] and the final check returns false. ")" pops an empty stack, undefined !== "(", so it also returns false. Characters that aren't brackets are ignored.
AdvancedBracket check complexity and pitfalls

O(n) time, O(n) space in the worst case (all openers). What an interviewer is checking: that you use a stack rather than counters — counting openers and closers can't detect "([)]", where the counts are fine but the nesting is wrong — and that you check both "wrong closer" and "leftovers at the end".

AdvancedQueues and the cost of shift

#Capitalize the first letter of each word

Input "hi how Are you" → output "Hi How Are You".

A short question, but it tests whether you notice the edge cases: what counts as a "word" (separated by one space, any whitespace, or anything that isn't a letter?), what happens to the other letters (keep them, or lowercase them for proper title case?), and what happens with repeated spaces and apostrophes. Each version below answers those questions differently.

JavaScript
// 1. split / map / join
const capitalize = (str) =>
  str
    .split(" ")
    .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
    .join(" ");

// 2. Regex: \b\w matches the first letter of each word
const capitalize2 = (str) => str.replace(/\b\w/g, (ch) => ch.toUpperCase());

// 3. Title case: also lowercase the rest
const titleCase = (str) =>
  str.toLowerCase().replace(/(^|\s)\S/g, (m) => m.toUpperCase());

capitalize("hi how Are you"); // "Hi How Are You"
titleCase("hI hOW aRE yOU");  // "Hi How Are You"
What’s happening
  1. capitalize splits on single spaces: "hi how Are you" → ["hi", "how", "Are", "you"]. For each word it upper-cases word[0] and glues on word.slice(1) unchanged: "Hi", "How", "Are", "You". join(" ") puts the same spaces back, giving "Hi How Are You".
  2. The word ? check matters for repeated spaces: "hi there".split(" ") is ["hi", "", "there"], and ""[0] is undefined, so .toUpperCase() would throw. Empty strings are passed through, and join restores the double space: "Hi There".
  3. capitalize2 uses a regex: \b is a word boundary (between a word character and a non-word character) and \w is [A-Za-z0-9_]. With the g flag, replace calls the callback for every letter that starts a word and upper-cases it. It handles tabs and hyphens too: "hello-world" → "Hello-World".
  4. titleCase first lower-cases everything ("hi how are you"), then matches (^|\s)\S: the start of the string or a whitespace character, followed by one non-whitespace character. The match includes the space, but upper-casing a space leaves it unchanged, so only the letter changes → "Hi How Are You".
  5. Versions 1 and 2 keep the rest of each word as it was ("hI hOW" → "HI HOW"); only version 3 produces true title case.
AdvancedEdge cases: extra spaces and apostrophes

#Custom JSON.stringify

A simplified version that covers the main rules: strings are quoted and escaped, undefined/functions/symbols are skipped in objects and become null in arrays, NaN/Infinity become null, and toJSON is respected (that's how Date works).

The shape of the solution is a recursive dispatch on type. Primitives are converted directly; arrays and objects are converted by stringifying each of their children (recursively) and joining the pieces with the right punctuation. The one subtle rule is that some values have no JSON representation, and what happens to them depends on where they are — so the function signals "nothing to write" by returning undefined, and lets the caller (the array or object branch) decide what to do about it.

stringify.js
function stringify(value) {
  if (value !== null && typeof value?.toJSON === "function") {
    value = value.toJSON();
  }

  if (value === null) return "null";

  switch (typeof value) {
    case "string":
      return `"${value
        .replace(/\\/g, "\\\\")
        .replace(/"/g, '\\"')
        .replace(/\n/g, "\\n")
        .replace(/\r/g, "\\r")
        .replace(/\t/g, "\\t")}"`;
    case "number":
      return Number.isFinite(value) ? String(value) : "null";
    case "boolean":
      return String(value);
    case "bigint":
      throw new TypeError("Do not know how to serialize a BigInt");
    case "undefined":
    case "function":
    case "symbol":
      return undefined; // caller decides: skip (object) or "null" (array)
  }

  if (Array.isArray(value)) {
    // Array.from (not map) so holes in sparse arrays are visited and become null
    return `[${Array.from(value, (item) => stringify(item) ?? "null").join(",")}]`;
  }

  const props = Object.keys(value)
    .map((key) => {
      const str = stringify(value[key]);
      return str === undefined ? undefined : `${stringify(key)}:${str}`;
    })
    .filter((p) => p !== undefined);
  return `{${props.join(",")}}`;
}

stringify({ a: 1, b: [true, undefined, "x"], c: undefined, d: new Date(0) });
// '{"a":1,"b":[true,null,"x"],"d":"1970-01-01T00:00:00.000Z"}'
What’s happening
  1. toJSON is checked first, so any object can choose its own representation. For new Date(0), toJSON() returns the string "1970-01-01T00:00:00.000Z", and from then on it's handled as a string. null is handled next because typeof null is "object" and it would otherwise fall into the object branch.
  2. Strings are wrapped in quotes and escaped. The backslash replacement must come first: if quotes were escaped first, the backslash just added before each " would then be doubled, turning " into \\" — an escaped backslash followed by a bare quote that ends the string early.
  3. Numbers go through Number.isFinite, so NaN, Infinity and -Infinity become "null"; String(-0) is "0", matching the real one. undefined, functions and symbols return undefined — "no JSON for this".
  4. For the sample object, Object.keys gives ["a", "b", "c", "d"]. a → "a":1. b is an array: true → true, undefined → undefined, which ?? "null" turns into null, "x" → "x", so "b":[true,null,"x"].
  5. c → stringify(undefined) is undefined, so the map returns undefined for that key and the filter drops it — the key disappears completely. d → "d":"1970-01-01T00:00:00.000Z". The surviving pieces are joined with commas inside {}; the result matches JSON.stringify exactly.
  6. Keys go through stringify(key) too, which reuses the quoting and escaping. Object.keys only returns own, enumerable, string keys, so symbol keys are skipped and a Map becomes {} — both match the built-in.
AdvancedWhat the real JSON.stringify adds

The real one also handles circular references (it throws), the replacer and space arguments, and boxed primitives like new String("x").

AdvancedSparse arrays and escaping differences

#Flatten an array (without flat)

Added

Flattening turns nested arrays into a single-level array: [1, [2, [3]]] → [1, 2, 3]. The built-in flat(depth) defaults to one level; flat(Infinity) flattens completely. The natural solution is recursive — "an element is either a value, which you keep, or an array, which you flatten and splice in" — and the follow-up is usually "now do it without recursion", which means managing your own stack instead of using the call stack.

JavaScript
// Recursive, with an optional depth like the real flat()
function flatten(arr, depth = Infinity) {
  return arr.reduce(
    (acc, item) =>
      Array.isArray(item) && depth > 0
        ? acc.concat(flatten(item, depth - 1))
        : acc.concat([item]),
    [],
  );
}

// Iterative with a stack — no recursion limit
function flattenIterative(arr) {
  const stack = [...arr];
  const result = [];
  while (stack.length) {
    const next = stack.pop();
    if (Array.isArray(next)) stack.push(...next);
    else result.push(next);
  }
  return result.reverse();
}

flatten([1, [2, [3, [4]]]]);    // [1, 2, 3, 4]
flatten([1, [2, [3, [4]]]], 1); // [1, 2, [3, [4]]]
flattenIterative([1, [2, [3]]]); // [1, 2, 3]
What’s happening
  1. flatten uses reduce with an empty-array accumulator. For each item: if it's an array and there's depth left, flatten it with one less depth and concatenate the result; otherwise append the item as-is.
  2. Trace flatten([1, [2, [3, [4]]]], 1). 1 is not an array → acc becomes [1]. [2, [3, [4]]] is an array and depth is 1, so it recurses with depth = 0.
  3. Inside that call depth > 0 is false, so every item is appended as-is: 2, then [3, [4]] wrapped as concat([[3, [4]]]), which adds the array as one element. That call returns [2, [3, [4]]], and the outer concat spreads it in: [1, 2, [3, [4]]].
  4. flattenIterative([1, [2, [3]]]) copies the input onto a stack and always takes from the end. Pop [2, [3]] → it's an array, so push its items: stack [1, 2, [3]]. Pop [3] → stack [1, 2, 3]. Pop 3 → result = [3]; pop 2 → [3, 2]; pop 1 → [3, 2, 1].
  5. Because it pops from the end, values come out in reverse order, so the final reverse() gives [1, 2, 3]. Popping from the end keeps every step O(1); taking from the front with shift would avoid the reverse but make each step O(n).
Advancedconcat([item]) and complexity

acc.concat([item]) (not acc.concat(item)) matters when depth runs out: concat would otherwise flatten one more level of a nested array. With acc.concat(item), flatten([1, [2, [3, [4]]]], 1) would wrongly give [1, 2, 3, [4]].

Complexity and what an interviewer probes: both visit every element once, but concat copies the accumulator on every step, so the reduce version is O(n²) for a long array; pushing into one shared result array makes it O(n). The recursive version can overflow the call stack on very deeply nested input, which is the reason for the iterative one — though stack.push(...next) passes each element as a separate argument, so a single inner array with hundreds of thousands of elements throws a RangeError there too. Holes differ as well: flatten([1, , 2]) gives [1, 2] like flat() (because reduce skips holes), while flattenIterative turns the hole into undefined.

#Deep equality

Added

=== on objects compares references — it asks "are these the same object?", not "do these look the same?". { a: 1 } === { a: 1 } is false. Deep equality compares structure instead: same type, same keys, and recursively equal values at every key. It's what test assertions like expect(x).toEqual(y) do, and what you need to decide whether state actually changed.

deepEqual.js
function deepEqual(a, b) {
  if (Object.is(a, b)) return true; // same reference, or equal primitives (NaN-safe)

  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
    return false;
  }
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  if (a instanceof Date) return a.getTime() === b.getTime();

  const keysA = Reflect.ownKeys(a);
  const keysB = Reflect.ownKeys(b);
  if (keysA.length !== keysB.length) return false;

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

deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true
deepEqual([1, 2], { 0: 1, 1: 2 });                     // false — different prototypes
deepEqual(NaN, NaN);                                   // true
What’s happening
  1. Object.is(a, b) is the fast path: it's true for the same reference and for equal primitives. Unlike === it says NaN equals NaN (so deepEqual(NaN, NaN) is true) and 0 does not equal -0.
  2. If either side isn't a non-null object, the values are different primitives (1 vs "1") or a primitive vs an object, and nothing else can make them equal → false.
  3. Comparing prototypes rules out structurally similar but different kinds of object: [1, 2] has Array.prototype and { 0: 1, 1: 2 } has Object.prototype, so they're unequal even though their indexed values match. Dates are special-cased because their value lives in an internal slot, not in a key — without this line every two dates would compare equal.
  4. Trace the first example. Reflect.ownKeys returns all own keys, including symbols and non-enumerable ones: ["a"] for both outer objects. The counts match, b has "a", so it recurses into the two arrays.
  5. For the arrays, own keys are ["0", "1", "length"] — note length is an own key of arrays. 1 vs 1 passes the Object.is fast path, { b: 2 } vs { b: 2 } recurses once more and matches, and length 2 vs 2 passes. Every level returns true.
  6. The length check plus Object.hasOwn(b, key) for every key of a together prove both objects have exactly the same key set, so key order doesn't matter: { a: 1, b: 2 } equals { b: 2, a: 1 }. every stops at the first mismatch.
AdvancedExtending to Maps, Sets and cycles

For Maps, Sets and circular structures, extend it (or use node:assert's deepStrictEqual / lodash isEqual).

AdvancedPitfall: Maps, Sets and RegExps compare equal
AdvancedDeep equality complexity and probes

Time is O(n) in the total number of keys and values visited; space is O(depth) for the recursion. What an interviewer is checking: NaN handling, null (which is typeof "object"), arrays vs objects, comparing key sets rather than key order, and whether you know the cases this version doesn't cover.

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.