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.
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.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.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.[[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.#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.Object.defineProperty with writable, enumerable, configurable, get/set; Object.freeze is shallow; getters are how libraries expose computed values without calls.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.=== (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, '').Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.toStringTag, Symbol.hasInstance, and Symbol.dispose/Symbol.asyncDispose from Explicit Resource Management (using), finished for ES2027.Closures as state, and the leak they can create:
// 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:
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:
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 },
})
}
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.
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.
var-loop bug and fix it three ways (let, IIFE, forEach); explain in one sentence what each changes about the captured variable.attachBad, take a heap snapshot after attaching 1,000 handlers with a 1 MB report each, find the retainer path, then switch to attachGood.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."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
The event loop and async
Tasks, microtasks and rendering, how await desugars, timer semantics, unhandled rejections, cancellation with AbortController, Promise combinators, breaking up long tasks, and the Node differences.
Modules and bundling
ESM semantics — static structure, live bindings, singleton modules, cycles and top-level await — versus CommonJS, Node interop and package exports, tree shaking and code splitting, and what Vite and Rolldown do with all of it.