JavaScript & the runtime

Closures, scope, this and prototypes

Lexical scope and the temporal dead zone, what a closure captures and what it keeps alive, the four rules for this, arrow functions and class fields, prototypes underneath classes, private fields, descriptors, Proxy and Reflect, and equality.

These are the "fundamentals" questions, and seniors are asked them for a reason: closures explain how composables hold state, this explains why a method breaks when passed as a callback, prototypes explain what a class actually is, and Proxy explains Vue's reactivity. Answer them with the model, then the rule, then the trap.

Know

  • Scope is lexical: decided by where code is written. let/const/class are block-scoped with a temporal dead zone (accessing before the declaration throws); var is function-scoped and hoisted as undefined; function declarations are hoisted whole. Modules and classes are strict mode.
  • A closure is a function plus the variables it references. It captures variables, not values: the classic var i loop logs the final i three times; let creates a fresh binding per iteration. A closure keeps its captured scope alive, and engines share one context object per scope, so a small callback can retain a large object declared next to the variable it uses.
  • this has four rules, checked in order: new binding (the new object), explicit binding (call/apply/bind), implicit binding (the object before the dot at the call site), default binding (undefined in strict mode, globalThis otherwise). Arrow functions have no this of their own and take it lexically; they also have no arguments, cannot be newed and ignore bind for this.
  • Losing this: extracting a method (const { inc } = counter) or passing it as a callback drops the implicit binding. Fixes: arrow class fields (inc = () => {}), bind in the constructor, or () => obj.inc() at the call site. DOM event handlers written as functions receive this === event.currentTarget.
  • Prototypes: every object has [[Prototype]] (Object.getPrototypeOf); property lookup walks the chain; assignment creates an own property that shadows; Object.create(proto) builds the chain directly; Object.hasOwn(obj, key) distinguishes own from inherited. class is syntax over this: methods live on Class.prototype, extends sets the chain (built-ins included), super calls up it, static members sit on the constructor.
  • Class features: public and private fields (#count, truly private, #x in obj brand checks), private methods, static blocks, accessors (get/set), instanceof (walks the chain; customisable via Symbol.hasInstance). Decorators are stage 3 and shipped in TypeScript; not yet in engines.
  • Property descriptors: Object.defineProperty with writable, enumerable, configurable, get/set; Object.freeze is shallow; getters are how libraries expose computed values without calls.
  • Proxy and Reflect: a Proxy intercepts operations (get, set, has, deleteProperty, ownKeys, apply, construct) with traps; Reflect provides the default behaviour to forward to. Vue 3's reactive() is a Proxy whose get tracks and whose set triggers; understanding the traps explains why reactive(obj) !== obj and why Map/Set need special handling.
  • Equality and coercion: === (no coercion, NaN !== NaN, +0 === -0), Object.is (distinguishes -0, treats NaN equal), SameValueZero used by Map, Set, includes. == coerces and is banned by most lint configs. Nullish coalescing (??, ??=) versus || for falsy-but-valid values (0, '').
  • Well-known symbols customise behaviour: Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.toStringTag, Symbol.hasInstance, and Symbol.dispose/Symbol.asyncDispose from Explicit Resource Management (using), finished for ES2027.

How it works

Closures as state, and the leak they can create:

app/utils/once.ts
// A closure holding private state: the pattern behind every composable.
export function createCounter(start = 0) {
  let count = start // captured variable, alive as long as inc/reset are reachable
  return {
    inc: () => ++count,
    reset: () => { count = start },
  }
}

// The leak: the handler only needs `id`, but the scope also holds `bigReport`,
// and one shared context object keeps both alive while the listener exists.
export function attachBad(el: HTMLElement, id: string, bigReport: object) {
  const summary = JSON.stringify(bigReport) // referenced below -> bigReport's scope stays alive
  el.addEventListener('click', () => console.log(id, summary.length))
}
// The fix: compute what you need eagerly and let the large value go out of scope.
export function attachGood(el: HTMLElement, id: string, bigReport: object) {
  const length = JSON.stringify(bigReport).length
  el.addEventListener('click', () => console.log(id, length), { signal: controllerFor(el).signal })
}

this, the four rules in one file:

this-rules.ts
class Timer {
  ticks = 0
  tick() { this.ticks++ }               // implicit binding: timer.tick()
  tickArrow = () => { this.ticks++ }     // arrow field: lexical this, safe as a callback
}
const timer = new Timer()                // new binding
timer.tick()                             // implicit: this === timer
const tick = timer.tick
try { tick() } catch { /* default binding: this is undefined in modules */ }
tick.call(timer)                         // explicit binding
setTimeout(timer.tickArrow, 0)           // works: arrow captured `this`
setTimeout(timer.tick.bind(timer), 0)    // works: bound

Prototypes without sugar, and a Proxy that mirrors Vue's idea:

proto-and-proxy.ts
const animal = { speak() { return `${this.name} makes a sound` } }
const dog = Object.create(animal, { name: { value: 'Rex', enumerable: true } })
dog.speak() // 'Rex makes a sound' via the chain; Object.hasOwn(dog, 'speak') === false

function reactive<T extends object>(target: T, onChange: (key: PropertyKey) => void): T {
  return new Proxy(target, {
    get(t, key, receiver) { return Reflect.get(t, key, receiver) }, // track(key) would go here
    set(t, key, value, receiver) {
      const changed = !Object.is(Reflect.get(t, key, receiver), value)
      const ok = Reflect.set(t, key, value, receiver)
      if (changed) onChange(key)
      return ok
    },
    deleteProperty(t, key) { const ok = Reflect.deleteProperty(t, key); onChange(key); return ok },
  })
}
Gotcha· Object.freeze is shallow

Object.freeze(config) prevents config.debug = true but not config.api.url = 'evil'. Deep immutability needs recursion or a library, and in Vue, readonly() is a deep, reactive alternative for state you expose.

Gotcha· Arrow methods on prototypes

inc = () => {} as a class field creates one function per instance (memory) and is not on the prototype (cannot be spied on the prototype, cannot use super). Use it for callbacks that must keep this; use normal methods for everything else.

Exercise

Exercise
  • Write the var-loop bug and fix it three ways (let, IIFE, forEach); explain in one sentence what each changes about the captured variable.
  • Build attachBad, take a heap snapshot after attaching 1,000 handlers with a 1 MB report each, find the retainer path, then switch to attachGood.
  • Implement the reactive Proxy above with a track/trigger pair and make a text node update when a property changes; you have reinvented the core of Vue.

Be able to say

Be able to say· What is a closure and why does it matter for leaks?

"A closure is a function together with the variables from the scopes where it was created; it captures the variables themselves, not their values, which is why a var loop logs the last index three times and let fixes it. Because the captured scope stays alive as long as the function is reachable, a tiny event handler can pin a large object declared in the same scope, and engines share one context object per scope, so even variables the handler never reads can be retained. The fix is to compute what the callback needs up front, keep large values out of long-lived scopes, and remove listeners, ideally with an AbortSignal, when the owner goes away."

Be able to say· How is this decided in JavaScript?

"At the call site, by four rules in priority order: new binds it to the fresh object; call, apply and bind set it explicitly; a call like obj.method() binds it to obj; and a bare call gives undefined in strict mode, which modules and classes always are. Arrow functions are the exception: they have no this and use the enclosing one, which is why arrow class fields make safe callbacks and why an arrow is wrong for a method that should be shared on the prototype. Losing this by passing obj.method as a callback is the everyday bug, fixed with an arrow, a bind, or a wrapper."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.