JavaScript Deep Dive — 02 · Objects, Prototypes & Metaprogramming
Most JavaScript bugs that survive code review are not algorithm bugs. They are object-model bugs: a callback that lost its this, a class field that silently shadowed a base-class setter, a "deep copy" that was one level deep, a Proxy that broke private fields, a cache that kept every request alive. Each of these follows directly from rules in the specification that are short, precise and rarely taught in full. This post states those rules: environments and the temporal dead zone, the four this bindings, the prototype chain and what class really builds, private fields and static blocks, property descriptors, symbols and the iteration protocols, Proxy and Reflect with their invariants, weak references, and the differences between structuredClone, spread and JSON copies.
Series map: 00 Engines & Runtime → 01 Event Loop & Concurrency → 02 Objects, Prototypes & Metaprogramming (this post) → 03 Async Engineering → 04 Modules, Packaging & TypeScript at Scale → 05 Performance, Memory & Production Debugging. Future: see the extensions table in 05.
Learning Outcome
- Explain scopes as environment records, and state exactly when the temporal dead zone (TDZ) applies.
- Recognise the closure-retention leak caused by shared scope contexts.
- Apply the four
thisrules in order of precedence, plus arrow functions and class fields. - Draw the prototype chain for a class hierarchy, including the constructor-side chain.
- Use private
#fields,#x in objbrand checks, static blocks and accessors, and know how class fields differ from assignments. - Read and write property descriptors; choose between
preventExtensions,sealandfreeze. - Implement the iterator protocol, use generators for lazy pipelines, and hook into well-known symbols.
- Build a
Proxythat forwards correctly withReflect, and respect its invariants. - Choose between
WeakMap,WeakRefandFinalizationRegistry, and betweenstructuredClone, spread and JSON for copies.
Chapter 1 — Scope, closures and the TDZ
Terminology
- Environment record — the specification's name for a scope: a map from names to bindings. Function calls, blocks, modules and the global scope each create one. Each record points to its outer record; name lookup walks that chain.
- Binding — a named slot.
varand function declarations create bindings that are initialised toundefined(or the function) when the scope is entered.let,constandclasscreate bindings that stay uninitialised until their declaration runs. - TDZ (temporal dead zone) — the time between entering a scope and executing a
let/const/classdeclaration. Any access to the binding in that window throwsReferenceError. - Closure — a function object plus a reference to the environment where it was created.
The TDZ, precisely
The TDZ is temporal, not textual: what matters is when the access runs, not where it is written.
1
2
3
4
5
6
7
8
9
10
11
12
function read() { return value; } // textually before the declaration
const value = 42;
read(); // 42: the call happens after initialisation
{
typeof t; // ReferenceError: Cannot access 't' before initialization
let t = 1; // (typeof does NOT protect a TDZ binding, unlike an undeclared name)
}
function f(a = b, b = 1) {} // parameters are initialised left to right
f(); // ReferenceError: Cannot access 'b' before initialization
Other TDZ cases that bite in real code: a class used before its declaration in the same module (classes are not hoisted like functions), and circular ES module imports, where a module reads an imported const before the exporting module has evaluated it — the error message names the binding, not the cycle.
Closures capture bindings, not values
1
2
3
4
5
6
7
8
const fns = [];
for (let i = 0; i < 3; i++) fns.push(() => i);
fns.map(f => f()); // [0, 1, 2]: `let` in a for-head gets a fresh binding per iteration
const gs = [];
for (var j = 0; j < 3; j++) gs.push(() => j);
gs.map(g => g()); // [3, 3, 3]: one function-scoped binding, read after the loop ends
The shared-context leak
V8 (like other engines) does not give each closure its own copy of the variables it uses. Variables captured by any closure in a scope go into one context object for that scope, and every closure created in that scope keeps the whole context alive:
1
2
3
4
5
6
function outer() {
const big = new Array(1e6).fill(0);
const unused = () => big.length; // captures `big` into outer's context
return () => 1; // never mentions `big`, but shares the context
}
Keeping 20 of the returned closures alive retained about 164 MB of heap in Node 22; the same code without unused retained about 4 MB. Real versions of this leak involve an event handler or a timer callback created in the same function as a closure that touched a large response or DOM tree.
Trick: when a long-lived callback is created in a function that also handles big data, move the callback's creation into a small separate function that receives only what it needs. The new scope has its own context, and the big object is no longer reachable.
Chapter 2 — this
this is not lexical (except in arrow functions). It is decided at call time, by how the function is called. In order of precedence:
| Rule | Call shape | this is |
|---|---|---|
1. new |
new F() |
The newly created object (a bound function's bound this is ignored) |
| 2. Explicit | f.call(x), f.apply(x), f.bind(x)() |
x |
| 3. Implicit (method) | obj.f(), obj['f']() |
obj — the object before the dot, at the moment of the call |
| 4. Default | f() |
undefined in strict code (modules and classes are always strict); globalThis in sloppy scripts |
| Arrow functions | any | The this of the enclosing scope; call/bind cannot change it |
1
2
3
4
5
6
7
8
9
const o = { name: 'o', f() { return this?.name; } };
const f = o.f;
f(); // undefined: rule 4, the "lost this"
[1].map(o.f); // [undefined]: same, the callback is called bare
function Foo() { this.a = 1; }
const Bound = Foo.bind({ a: 99 });
new Bound().a; // 1: `new` beats `bind`
Where it goes wrong in practice:
- Passing methods as callbacks —
button.addEventListener('click', this.onClick),promise.then(service.load),setTimeout(obj.tick, 100). For DOM listenersthisbecomes the element; for the others,undefined. - Destructuring methods —
const { log } = consoleworks only becauseconsolemethods happen not to rely onthisin modern engines;const { push } = arrdoes not.
Fixes, with their trade-offs:
1
2
3
4
5
6
7
8
9
class Clicker {
count = 0;
onClick = () => { this.count++; }; // arrow in a class field: one function PER INSTANCE
onKey() { this.count++; } // method: one function on the prototype, shared
}
const c = new Clicker();
el.addEventListener('click', c.onClick); // works
el.addEventListener('keydown', () => c.onKey()); // wrapper at the call site
Watch out: arrow-function class fields are convenient, but they live on each instance, not on the prototype. They cannot be overridden with
super.onClick()in a subclass, do not appear on the prototype for mocking in tests, and cost one closure per instance — noticeable for classes instantiated thousands of times.
Chapter 3 — The prototype chain
Every object has an internal slot [[Prototype]] — another object or null. Reading a property walks the chain until it finds an own property with that key; writing does something subtler.
What class builds
1
2
3
4
5
6
7
8
9
10
11
class Base {
constructor(id) { this.id = id; }
describe() { return `#${this.id}`; }
static create(id) { return new this(id); }
}
class User extends Base {
constructor(id, name) { super(id); this.name = name; }
describe() { return `${super.describe()} ${this.name}`; }
}
const u = new User(7, 'Ada');
There are two chains:
- Instance side:
u→User.prototype(hasdescribe) →Base.prototype(hasdescribe) →Object.prototype→null. - Constructor side:
User→Base→Function.prototype→Object.prototype→null. This is whyUser.create(1)works: static methods are inherited becauseObject.getPrototypeOf(User) === Base.
Facts that follow from the spec and matter in code review:
- Class methods are non-enumerable, so they never show up in
for…in,Object.keysor spread. - Class bodies are strict mode, and calling a class without
newthrowsTypeError: Class constructor User cannot be invoked without 'new'. super.describe()is resolved through the method's home object (the object it was defined on), not throughthis, so it keeps working when the method is borrowed.instanceofchecks whetherF.prototypeappears on the chain (unlessFdefinesSymbol.hasInstance). It fails across realms — an array from an iframe is notinstanceof Arrayin the parent. PreferArray.isArray, or brand checks (Chapter 4).
Writing is not symmetric with reading
obj.x = v does not simply create an own property. If x is found on the prototype chain:
| Found on the chain as | Result of obj.x = v |
|---|---|
| Nothing | Creates an own data property on obj |
| A writable data property | Creates an own property on obj (shadowing) |
| A non-writable data property | Assignment fails: TypeError in strict mode, silently ignored in sloppy mode. No own property is created |
| An accessor (setter) | Calls the inherited setter with this = obj; no own property is created |
1
2
3
4
5
6
7
const proto = {};
Object.defineProperty(proto, 'id', { value: 1, writable: false });
const obj = Object.create(proto);
obj.id = 2; // TypeError: Cannot assign to read only property 'id' of object
Object.defineProperty(obj, 'id', { value: 2, writable: true, enumerable: true, configurable: true });
obj.id; // 2: defineProperty creates the own property regardless
This asymmetry between assignment ([[Set]], follows the chain) and definition ([[DefineOwnProperty]], does not) is the key to class fields in the next chapter.
Common pitfall: using a plain object as a dictionary of user-supplied keys.
dict['__proto__'] = valuehits the inherited__proto__accessor and changes the object's prototype instead of storing a key — the root of many prototype-pollution vulnerabilities. UseMap, orObject.create(null)for a chain-free object.
Chapter 4 — Classes in depth
Fields are defined, not assigned
Class fields use define semantics (like Object.defineProperty), not assignment:
1
2
3
4
5
class Base { set v(x) { console.log('setter called', x); } }
class Derived extends Base { v = 5; }
const d = new Derived(); // nothing logged: the setter is NOT called
Object.getOwnPropertyDescriptor(d, 'v'); // { value: 5, writable: true, enumerable: true, configurable: true }
The own property v now shadows the base-class accessor. Code migrated from constructor assignments (this.v = 5, which would call the setter) to fields changes behaviour. TypeScript follows the standard when useDefineForClassFields is true (the default for targets ES2022 and later); older configurations emitted assignments, so upgrading the target can change behaviour for exactly this pattern.
Private members and brand checks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Account {
#balance = 0; // private field: per instance, not on the prototype
static #registry = new Set(); // private static field
static { // static initialization block (ES2022)
Account.#registry.clear(); // runs once, with access to private members
}
#audit(msg) { /* private method */ }
deposit(n) {
if (!(n > 0)) throw new RangeError('amount must be positive');
this.#balance += n;
this.#audit(`deposit ${n}`);
}
get balance() { return this.#balance; } // accessor
static isAccount(x) { return #balance in x; } // ergonomic brand check (ES2022)
}
Private names are lexically scoped to the class body and are not properties at all: they are not reachable by Object.keys, Reflect.ownKeys, Proxy traps or structuredClone. Accessing #balance on an object that lacks it throws TypeError — which makes #x in obj a reliable, realm-independent brand check, unlike instanceof.
Watch out: private fields and
Proxydo not mix. A method called through a proxy hasthis === proxy, and the proxy object has no private fields:
1
2
3
4class Counter { #n = 0; inc() { return ++this.#n; } }
new Proxy(new Counter(), {}).inc();
// TypeError: Cannot read private member #n from an object whose class did not declare it
Any library that wraps your objects in proxies (Vue's
reactive()is the best-known example) hits exactly this. Either keep such classes out of proxied state, or have their methods work on the raw target.
Chapter 5 — Property descriptors and integrity levels
Every own property has a descriptor. Data properties have value and writable; accessor properties have get and set; both have enumerable and configurable.
| Created by | writable |
enumerable |
configurable |
|---|---|---|---|
| Assignment or object literal | true |
true |
true |
| Class field | true |
true |
true |
| Class method | true |
false |
true |
Object.defineProperty with fields omitted |
false |
false |
false |
The last row is a frequent surprise: Object.defineProperty(o, 'k', { value: 1 }) creates a read-only, hidden, permanent property. Object.keys(o) then returns [].
configurable: false means the property cannot be deleted and its descriptor can no longer change — with one exception: a writable data property may still be made non-writable.
Integrity levels
| Add properties | Delete | Reconfigure | Change values | |
|---|---|---|---|---|
Object.preventExtensions(o) |
No | Yes | Yes | Yes |
Object.seal(o) |
No | No | No | Yes |
Object.freeze(o) |
No | No | No | No (data properties) |
All three are shallow: Object.freeze({ inner: { x: 1 } }).inner.x = 2 succeeds. Freezing also does not stop accessor properties from changing internal state. A deep freeze must recurse, and it should be a development-time guard (freezing Redux state in dev builds is a common use), not a security boundary.
Chapter 6 — Symbols, iterators and generators
Symbols
A Symbol() is a unique, non-string property key. Symbol('x') !== Symbol('x'); Symbol.for('x') returns a shared, registry-wide symbol (the same across realms). Symbol-keyed properties are skipped by Object.keys, for…in and JSON.stringify, but are copied by spread and Object.assign, and listed by Reflect.ownKeys. They are hidden from casual enumeration, not private.
Well-known symbols are the engine's hooks into your objects:
| Symbol | Hook | Used by |
|---|---|---|
Symbol.iterator |
Return an iterator | for…of, spread, destructuring, Array.from, Promise.all |
Symbol.asyncIterator |
Return an async iterator | for await…of |
Symbol.toPrimitive |
Convert to a primitive, given a hint ('number', 'string', 'default') |
+x, template literals, x + '' |
Symbol.hasInstance |
Custom instanceof |
x instanceof C |
Symbol.toStringTag |
Tag for Object.prototype.toString |
[object Tag] in logs and type checks |
Symbol.species |
Constructor for derived objects | map/filter on Array subclasses, then on Promise subclasses |
Symbol.dispose, Symbol.asyncDispose |
Cleanup hook | using / await using declarations (explicit resource management; shipped in Chrome 134 and Node 24, check other engines; Post 03) |
1
2
3
4
5
6
7
8
9
const price = {
[Symbol.toPrimitive](hint) {
return hint === 'number' ? 42 : hint === 'string' ? '$42' : 'default';
},
};
+price; // 42
`${price}`; // '$42'
price + ''; // 'default' (binary + uses the 'default' hint)
The iteration protocols
An iterable has a [Symbol.iterator]() method returning an iterator: an object with next() returning { value, done }, and optionally return(). for…of calls return() when the loop exits early (break, return, throw) — this is how iterators release resources.
1
2
3
4
5
6
7
8
9
10
11
const ticks = {
[Symbol.iterator]() {
let i = 0;
return {
next: () => ({ value: i++, done: false }),
return() { console.log('cleanup'); return { done: true }; },
};
},
};
for (const t of ticks) if (t === 2) break; // logs "cleanup"
Generators
A generator function returns an object that is both iterable and an iterator, with the protocol implemented for you. It runs lazily, one yield at a time, and its finally blocks run when a consumer stops early:
1
2
3
4
5
6
7
8
9
function* readLines(text) {
const handle = open(text); // pretend resource
try {
for (const line of handle) yield line;
} finally {
handle.close(); // runs on normal end, on break, and on throw
}
}
Since ES2025, iterator helpers give every built-in iterator lazy map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every and find, and Iterator.from() wraps any iterable:
1
2
3
function* naturals() { let n = 0; while (true) yield n++; }
naturals().filter(n => n % 2).map(n => n * n).take(3).toArray(); // [1, 9, 25]
Nothing is computed until toArray() pulls values, so an infinite source is fine. Unlike array methods, no intermediate arrays are allocated.
Trick:
yield*delegates to another iterable and forwardsreturn()andthrow()to it, so composed generators still clean up correctly. Hand-written iterator wrappers usually forget to forwardreturn()and leak the inner resource.
Chapter 7 — Proxy and Reflect
A Proxy wraps a target and intercepts the object's internal methods through traps: get, set, has, deleteProperty, ownKeys, getOwnPropertyDescriptor, defineProperty, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, and for functions apply and construct. Reflect has a method with the same name and signature for every trap, performing the default behaviour.
Forward with Reflect, and pass the receiver
1
2
3
4
5
6
7
8
9
10
11
12
const person = { first: 'Ada', last: 'Lovelace', get full() { return `${this.first} ${this.last}`; } };
const wrong = new Proxy(person, {
get(target, key) { return key === 'first' ? 'ADA' : target[key]; },
});
wrong.full; // 'Ada Lovelace': the getter ran with this = target, bypassing the proxy
const right = new Proxy(person, {
get(target, key, receiver) { return key === 'first' ? 'ADA' : Reflect.get(target, key, receiver); },
});
right.full; // 'ADA Lovelace': the getter ran with this = proxy, so its reads were trapped too
The receiver argument is the object the operation started on. Forwarding it is what makes getters, setters and inheritance (a proxy used as a prototype) behave correctly.
Invariants
A proxy may lie, but not about facts the target has made permanent. The engine checks each trap's result against the target and throws TypeError on a violation. The main ones:
getmust return the actual value for a non-writable, non-configurable own data property of the target.hasandgetOwnPropertyDescriptorcannot hide a non-configurable property, or any property of a non-extensible target.ownKeysmust include every non-configurable own key; for a non-extensible target it must list exactly the target's keys.isExtensiblemust match the target;getPrototypeOfmust match for a non-extensible target.
1
2
3
4
5
const frozen = Object.freeze({ k: 1 });
new Proxy(frozen, { get: () => 2 }).k;
// TypeError: 'get' on proxy: property 'k' is a read-only and non-configurable data property
// on the proxy target but the proxy did not return its actual value (expected '1' but got '2')
This is why you cannot make a "read-only view" of a frozen object that rewrites values, and why reactive systems proxy mutable targets.
Costs and limits
- Identity:
proxy !== target. Sets, maps and===checks see two different objects; libraries keep aWeakMapfrom target to proxy to return the same proxy each time. - Internal slots: built-ins keep data in internal slots that a proxy does not have.
new Proxy(new Map(), {}).get(1)throws (Map.prototype.getcalled on an incompatible receiver); the same applies toDate,Set, typed arrays and private fields. - Speed: every trapped operation is a function call, and proxied access is much slower than a plain property load in optimised code (Post 00's inline caches cannot see through a trap). Keep proxies at boundaries — validation, reactivity, API clients — not in hot inner loops.
Proxy.revocable(target, handler)returns{ proxy, revoke }; afterrevoke(), every operation on the proxy throws. Useful for handing out capabilities that can be withdrawn.
Chapter 8 — Weak references and copying
Weak collections
| Keys / target | Enumerable | Typical use | |
|---|---|---|---|
WeakMap |
Objects and non-registered symbols | No | Per-object metadata and caches that die with the object |
WeakSet |
Same | No | Marking objects ("already visited", "already wrapped") |
WeakRef |
One object | — | A cache entry that may disappear; deref() returns the object or undefined |
FinalizationRegistry |
Callback after an object is collected | — | Releasing an external resource as a backstop |
A WeakMap entry keeps its value alive only while the key is alive — the right structure for "extra data about objects I do not own" (DOM nodes, request objects, library instances):
1
2
3
4
const meta = new WeakMap();
function track(req) { meta.set(req, { start: performance.now() }); }
// when `req` becomes unreachable, its entry is collectable too: no manual delete, no leak
WeakRef and FinalizationRegistry are deliberately weak guarantees. The specification allows a finalization callback to run late or never (for example, when the page closes). Within one synchronous job, deref() keeps returning the same object (the engine keeps it alive until the job ends), so check once and use the result. Never put essential cleanup — closing a file, releasing a lock — in a finalizer; use try/finally or using, and treat the registry as a leak detector or a last-resort backstop.
Copying: structuredClone vs spread vs JSON
Spread / Object.assign |
JSON.parse(JSON.stringify(x)) |
structuredClone(x) |
|
|---|---|---|---|
| Depth | Shallow | Deep | Deep |
Date |
Same object shared | Becomes a string | Date |
Map, Set |
Same object shared | Becomes {} |
Map, Set |
undefined values |
Kept | Key dropped | Kept |
NaN, Infinity |
Kept | Become null |
Kept |
BigInt |
Kept | Throws | Kept |
| Circular references | Shared (shallow) | Throws | Preserved |
| Functions | Copied by reference | Dropped | DataCloneError |
| Getters | Invoked; value copied | Invoked; value copied | Invoked; value copied |
| Prototype / class | Lost (plain object) | Lost | Lost |
| Symbol keys | Copied | Dropped | Dropped |
Private #fields |
Not copied | Not copied | Not copied |
(Cells checked in Node 22.) The practical rules:
- Spread for one level of immutable update (
{ ...state, user: { ...state.user, name } }), which is what React and Redux reducers need. structuredClonefor a real deep copy of data — includingDate,Map,Set, typed arrays, cycles.- Neither preserves behaviour: class instances come back as plain objects. To copy an instance, give the class its own
clone()method. - JSON round-trips are for serialisation, not copying.
Practice Exercises
Exercise 1 — TDZ or not?
Which lines throw?
1
2
3
4
5
6
7
console.log(typeof undeclared); // (a)
console.log(typeof later); // (b)
let later = 1;
function g() { return helper(); } // (c) g is called at the end
const helper = () => 'ok';
g();
Solution
Only (b) throws ReferenceError: later is declared with let in the same scope and is still in the TDZ. (a) prints 'undefined' — typeof is safe for names that are not declared at all. (c) works: helper is read when g() runs, after its initialisation. The TDZ is about time, not position.
Exercise 2 — What is this?
1
2
3
4
5
6
7
8
9
'use strict';
const counter = {
n: 0,
inc() { this.n++; return this.n; },
later() { setTimeout(function () { console.log(this?.n); }, 0); },
laterArrow() { setTimeout(() => console.log(this.n), 0); },
};
const { inc } = counter;
What do counter.inc(), inc(), counter.later() and counter.laterArrow() do?
Solution
counter.inc() returns 1 (implicit binding). inc() throws TypeError (this is undefined in strict code, so this.n fails). counter.later() logs undefined: the callback is a plain function called by the timer (in Node this is the Timeout object; in browsers it is window), neither of which has n. counter.laterArrow() logs the current n, because the arrow takes this from laterArrow, which was called as a method.
Exercise 3 — The setter that stopped firing
A base class has set value(v) { this.#validate(v); … }. A subclass declares value = 0; as a field. Validation stopped running after a TypeScript target upgrade to ES2022. Why, and what are two fixes?
Solution
With useDefineForClassFields (default for ES2022+ targets), the field is defined as an own data property on the instance, which shadows the inherited accessor; the setter never runs, now or on later assignments. Fixes: (1) remove the field and assign in the constructor (constructor() { super(); this.value = 0; }), which goes through [[Set]] and calls the setter; or (2) use TypeScript's declare value: number; if the field was only there for typing, which emits nothing.
Exercise 4 — A logging proxy that works
Write logged(obj) returning a proxy that logs every property read and write, works with getters and setters that use this, and returns the same proxy when called twice with the same object.
Solution
1
2
3
4
5
6
7
8
9
10
11
const cache = new WeakMap();
function logged(obj) {
if (cache.has(obj)) return cache.get(obj);
const proxy = new Proxy(obj, {
get(t, k, r) { console.log('get', k); return Reflect.get(t, k, r); },
set(t, k, v, r) { console.log('set', k, v); return Reflect.set(t, k, v, r); },
});
cache.set(obj, proxy);
return proxy;
}
Passing receiver to Reflect.get/Reflect.set makes accessors run with this = proxy, so their inner reads are logged too. The WeakMap keeps proxy identity stable without keeping obj alive. It still fails for objects with private fields or internal slots (Map, Date) — by design of Proxy.
Exercise 5 — A lazy pipeline
Using generators and iterator helpers, write chunks(iterable, size) that yields arrays of size items, and use it to process the first 3 chunks of an infinite sequence without computing more.
Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
function* chunks(iterable, size) {
let buf = [];
for (const x of iterable) {
buf.push(x);
if (buf.length === size) { yield buf; buf = []; }
}
if (buf.length) yield buf;
}
function* naturals() { let n = 0; while (true) yield n++; }
Iterator.from(chunks(naturals(), 4)).take(3).toArray();
// [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
take(3) calls return() on the chunk generator after the third chunk, which in turn exits its for…of, which calls return() on naturals(). The infinite source stops cleanly.
Exercise 6 — Pick the copy
For each, choose spread, structuredClone or neither: (a) update one field of a Redux state slice; (b) snapshot a form's state (with Date and Set values) for undo; (c) copy an instance of class Money with methods; (d) send a nested config object to a worker.
Solution
(a) Spread — shallow is exactly right, and unchanged branches keep their identity for cheap equality checks. (b) structuredClone — deep, keeps Date and Set. (c) Neither — both return plain objects; add clone() to the class. (d) Neither by hand — postMessage already applies structured clone; just make sure the object holds no functions or class instances whose behaviour you rely on.
Cheat-sheet
| Topic | Rule |
|---|---|
| TDZ | let/const/class unusable until the declaration runs; typeof does not help |
| Closure leak | Closures in one scope share one context; move long-lived callbacks to their own function |
this precedence |
new > call/apply/bind > obj.f() > default (undefined in strict); arrows are lexical |
| Assignment vs definition | obj.x = v follows the chain (setters, read-only); defineProperty and class fields do not |
| Brand check | #field in obj (realm-safe) instead of instanceof |
defineProperty defaults |
writable, enumerable, configurable all false |
| Integrity | preventExtensions < seal < freeze; all shallow |
| Iteration | [Symbol.iterator]() → { next, return }; for…of calls return() on early exit |
| Lazy pipelines | Generators + iterator helpers (map, filter, take, toArray), ES2025 |
| Proxy forwarding | Reflect.<trap>(target, …args, receiver) |
| Proxy limits | Invariants on non-configurable props; no private fields or internal slots; slower |
| Finalizers | Not guaranteed to run; backstop only |
| Deep copy of data | structuredClone (no functions, no prototypes) |
What's next
Post 03 is Async Engineering: promises as a state machine and their combinators, async iteration and streams, cancellation with AbortController and AbortSignal, backpressure and concurrency limits, retries with jitter, using / await using for deterministic cleanup (built on Symbol.dispose from this post), and error handling that survives production — unhandled rejections, error cause chains and aggregate errors. It builds on the queue model from Post 01 and the iterator protocols from this post.
References
- ECMAScript specification — Environment Records · Ordinary object internal methods · Proxy object internal methods and invariants
- MDN —
this - MDN — Inheritance and the prototype chain
- MDN — Public class fields · Private properties · Static initialization blocks
- MDN —
Object.defineProperty - MDN — Iteration protocols · Iterator helpers (
Iterator) - MDN —
Proxy·Reflect - MDN —
WeakRef·FinalizationRegistry - MDN —
structuredClone() - TypeScript —
useDefineForClassFields










