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
varis function-scoped and hoisted with the valueundefined.letandconstare block-scoped. They are hoisted too, but sit in the temporal dead zone until their declaration runs, and accessing them early throws aReferenceError.- 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 form | this |
|---|---|
obj.method() | obj |
fn() plain call | undefined 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 function | the 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, noarguments, and cannot be used withnew.
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.freezeis shallow. - Destructuring and optional chaining (
a?.b?.c), nullish coalescing (??, which only treatsnullandundefinedas 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 atyield.for...ofconsumes any iterable;for...initerates 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
thisin callbacks. - Mutating shared objects after a shallow copy.
||for defaults when0or""are valid values.- Believing
typeof nullis a bug you can fix with== nullchecks; usevalue == nulldeliberately 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
- What are the differences between
var,letandconst? What is the temporal dead zone? - What does a closure capture, and what are its risks?
- List the rules that decide
this. How do arrow functions differ? - Explain prototypal inheritance. What does
newdo? - Predict and explain:
console.log(typeof null, [] == false, [10, 9, 1].sort()). - Shallow versus deep copy: how would you clone a nested object with dates?
- When does
??differ from||? - Implement
bindwithout usingbind.