Intermediate to senior

Frontend Interview Prep

Fourteen chapters on HTML and CSS, core JavaScript, the event loop, browser rendering, React, state, performance, accessibility, security, TypeScript, testing, machine-coding components and frontend system design, with tested code.

Chapter 3 of 14Foundations · JavaScript Core: Scope, Closures, this and Prototypes

JavaScript Core: Scope, Closures, this and Prototypes

Core JavaScript questions are the fastest way for an interviewer to see whether you understand the language or only the framework on top of it. Most "what does this print?" puzzles reduce to five ideas: scope and hoisting, closures, this binding, prototypes, and equality and coercion. Every snippet below is runnable and checked.

1. Values, types and equality

JavaScript has primitives (string, number, bigint, boolean, undefined, null, symbol) and objects (including arrays and functions). Primitives are immutable and compared by value; objects are compared by reference.

assert.equal(typeof 1, "number");
assert.equal(typeof null, "object");            // a historical quirk: null is not an object
assert.equal(typeof undefined, "undefined");
assert.equal(typeof (() => 1), "function");
assert.equal(Array.isArray([]), true);

// == coerces, === does not
assert.ok(0 == "");
assert.ok(null == undefined);
assert.ok(!(null === undefined));
assert.ok(NaN !== NaN);                          // the only value not equal to itself
assert.ok(Object.is(NaN, NaN));
assert.ok(!Object.is(0, -0));                    // Object.is separates +0 and -0

// objects compare by reference
assert.ok({} !== {});
const o = {};
assert.ok(o === o);

Use === by default. Know the common coercion results: [] + [] is "", [] + {} is "[object Object]", "5" - 2 is 3, "5" + 2 is "52".

assert.equal([] + [], "");
assert.equal("5" - 2, 3);
assert.equal("5" + 2, "52");
assert.equal(Number("  42 "), 42);
assert.ok(Number.isNaN(Number("abc")));
assert.equal(0.1 + 0.2 === 0.3, false);          // binary floating point
assert.ok(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);

Falsy values: false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy, including "0", [] and {}.

2. Scope, hoisting and the temporal dead zone

  • var is function-scoped and hoisted with the value undefined.
  • let and const are block-scoped. They are hoisted too, but sit in the temporal dead zone until their declaration runs, and accessing them early throws a ReferenceError.
  • Function declarations are hoisted with their body; function expressions behave like the variable that holds them.
function hoistDemo() {
  const before = typeof x;          // "undefined": x is hoisted as undefined
  var x = 5;
  return before;
}
assert.equal(hoistDemo(), "undefined");

assert.throws(() => { y; let y = 1; }, ReferenceError);      // temporal dead zone

assert.equal(sayHi(), "hi");        // a function declaration is hoisted with its body
function sayHi() { return "hi"; }

The classic loop

const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
assert.deepEqual(withVar.map(f => f()), [3, 3, 3]);      // one shared i

const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
assert.deepEqual(withLet.map(f => f()), [0, 1, 2]);      // a fresh j per iteration

3. Closures

A closure is a function plus a reference to the variables of the scope in which it was defined. The variables stay alive as long as the function can be reached.

function makeCounter() {
  let count = 0;                            // private state
  return {
    inc: () => ++count,
    get: () => count,
  };
}
const a = makeCounter(), b = makeCounter();
a.inc(); a.inc(); b.inc();
assert.equal(a.get(), 2);
assert.equal(b.get(), 1);                   // each call creates its own closure

Uses: private state, factories, callbacks that remember context, partial application, memoisation, event handlers, module patterns. Pitfalls: accidental retention of large objects (memory leaks), stale values captured by callbacks (a common React bug), and the var loop above.

function once(fn) {
  let done = false, result;
  return function (...args) {
    if (!done) { done = true; result = fn.apply(this, args); }
    return result;
  };
}
let calls = 0;
const init = once(() => ++calls);
init(); init();
assert.equal(calls, 1);

4. this

this is decided by how a function is called, not where it is written (arrow functions excepted).

Call formthis
obj.method()obj
fn() plain callundefined in strict mode and modules (the global object in sloppy mode)
new Fn()the new object
fn.call(x) / .apply(x) / .bind(x)x
Arrow functionthe this of the enclosing scope (lexical); cannot be rebound
DOM event handler (function)the element
const obj = {
  name: "obj",
  regular() { "use strict"; return this === undefined ? "lost" : this.name; },
  delayed() { return [1].map(() => this.name)[0]; },   // an arrow keeps obj as this
};
assert.equal(obj.regular(), "obj");
const detached = obj.regular;
assert.equal(detached(), "lost");             // the method was detached, so this is undefined in strict code
assert.equal(obj.delayed(), "obj");

function greet(greeting) { return greeting + ", " + this.name; }
assert.equal(greet.call({ name: "Asha" }, "Hello"), "Hello, Asha");
const bound = greet.bind({ name: "Ravi" });
assert.equal(bound("Hi"), "Hi, Ravi");
assert.equal(bound.call({ name: "ignored" }, "Hey"), "Hey, Ravi");   // bind wins over call

Losing this happens when you pass a method as a callback (setTimeout(obj.method), arr.map(obj.method), onClick={this.handle}). Fix with an arrow wrapper, .bind, or class fields written as arrows.

5. Prototypes and classes

Every object has an internal link to a prototype. Property lookup walks up the chain until it finds the name or reaches null.

function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return this.name + " makes a sound"; };

const dog = new Animal("Rex");
assert.equal(dog.speak(), "Rex makes a sound");
assert.equal(Object.getPrototypeOf(dog), Animal.prototype);
assert.ok(dog.hasOwnProperty("name") && !dog.hasOwnProperty("speak"));   // speak is inherited

class Dog extends Animal {
  speak() { return super.speak() + " (woof)"; }
}
const d = new Dog("Fido");
assert.equal(d.speak(), "Fido makes a sound (woof)");
assert.ok(d instanceof Dog && d instanceof Animal);
assert.equal(typeof Dog, "function");           // classes are syntax over prototypes and constructors

new does four things: create an object whose prototype is Fn.prototype, run Fn with this set to it, and return it (unless the function returns an object). You can write it yourself:

function myNew(Ctor, ...args) {
  const obj = Object.create(Ctor.prototype);
  const result = Ctor.apply(obj, args);
  return result !== null && (typeof result === "object" || typeof result === "function") ? result : obj;
}
assert.equal(myNew(Animal, "Tom").speak(), "Tom makes a sound");

Methods on the prototype are shared by all instances; methods assigned in the constructor create a new function per instance. Private fields use #name and are truly inaccessible from outside.

6. Functions as values

  • First-class: pass, return and store them.
  • Higher-order functions: map, filter, reduce.
  • Currying and partial application build specialised functions.
  • Default, rest and spread: function f(a = 1, ...rest), [...arr], {...obj} (shallow copies).
  • Arrow functions have no own this, no arguments, and cannot be used with new.
const compose = (...fns) => x => fns.reduceRight((acc, f) => f(acc), x);
const pipe = (...fns) => x => fns.reduce((acc, f) => f(acc), x);
const add1 = x => x + 1, dbl = x => x * 2;
assert.equal(compose(add1, dbl)(5), 11);          // dbl first, then add1
assert.equal(pipe(add1, dbl)(5), 12);

const nums = [1, 2, 3, 4];
assert.equal(nums.reduce((s, n) => s + n, 0), 10);
assert.deepEqual(nums.filter(n => n % 2).map(n => n * n), [1, 9]);

7. Objects, copying and immutability

  • Shallow copy: {...o}, Object.assign({}, o), arr.slice(), [...arr]. Nested objects are still shared.
  • Deep copy: structuredClone(value) (handles dates, maps, sets, cycles; not functions) or a recursive function.
  • Immutability helps change detection in React and avoids hidden mutation. Object.freeze is shallow.
  • Destructuring and optional chaining (a?.b?.c), nullish coalescing (??, which only treats null and undefined as missing, unlike ||).
const original = { a: 1, nested: { b: 2 } };
const shallow = { ...original };
shallow.nested.b = 99;
assert.equal(original.nested.b, 99);               // the nested object is shared

const deep = structuredClone({ a: 1, nested: { b: 2 }, when: new Date(0) });
assert.ok(deep.when instanceof Date);

assert.equal(0 || "default", "default");            // || treats 0 as missing
assert.equal(0 ?? "default", 0);                     // ?? keeps 0
assert.equal(undefined?.x, undefined);

8. Modules and iteration

  • ES modules (import/export) are static, so tools can tree-shake; CommonJS (require) is dynamic. ES module bindings are live references, not copies.
  • Iterators and generators: objects with next(); function* pauses at yield. for...of consumes any iterable; for...in iterates enumerable keys (avoid on arrays).
  • Map and Set preserve insertion order, accept any key type, and are often better than plain objects as dictionaries. WeakMap and WeakSet hold keys weakly so they do not prevent garbage collection.
function* range(start, end) { for (let i = start; i < end; i++) yield i; }
assert.deepEqual([...range(0, 4)], [0, 1, 2, 3]);

const m = new Map([[1, "a"]]);
m.set({ k: 1 }, "obj");
assert.equal(m.size, 2);
assert.deepEqual([...new Set([1, 2, 2, 3, 1])], [1, 2, 3]);

9. Common mistakes

  • Using == and trusting coercion.
  • Capturing loop variables with var.
  • Losing this in callbacks.
  • Mutating shared objects after a shallow copy.
  • || for defaults when 0 or "" are valid values.
  • Believing typeof null is a bug you can fix with == null checks; use value == null deliberately for "null or undefined".
  • Sorting numbers with the default sort(), which compares strings. Pass (a, b) => a - b.
  • Floating-point equality without a tolerance.
assert.deepEqual([10, 9, 1].sort(), [1, 10, 9]);                 // lexicographic
assert.deepEqual([10, 9, 1].sort((a, b) => a - b), [1, 9, 10]);

10. Practice questions

  1. What are the differences between var, let and const? What is the temporal dead zone?
  2. What does a closure capture, and what are its risks?
  3. List the rules that decide this. How do arrow functions differ?
  4. Explain prototypal inheritance. What does new do?
  5. Predict and explain: console.log(typeof null, [] == false, [10, 9, 1].sort()).
  6. Shallow versus deep copy: how would you clone a nested object with dates?
  7. When does ?? differ from ||?
  8. Implement bind without using bind.
Header Logo