Deep Object and Meta-Programming Semantics
Property descriptors and integrity levels precisely, symbols as collision-free keys, and Proxy/Reflect for intercepting fundamental object operations — with real observability and performance costs.
By the end of this lesson, you can
- Explain what each integrity level (sealed, frozen, non-extensible) actually locks down
- Use a well-known symbol to customize how a built-in operation treats an object
- Build a Proxy that intercepts a fundamental operation while respecting its invariants
- Explain why a Proxy-based design has real observability and debugging costs worth weighing against a simpler alternative
Why it matters
Objects, Prototypes, Classes, and this
covered descriptors and the prototype chain at an everyday level. Meta-programming — code that
manipulates how objects fundamentally behave, rather than just their data — needs a more precise
model of descriptors, and a genuinely powerful (and genuinely risky) new tool: Proxy.
Integrity levels, precisely
| Operation | Object.preventExtensions | Object.seal | Object.freeze |
|---|---|---|---|
| Add a new property | Blocked | Blocked | Blocked |
| Delete an existing property | Allowed | Blocked | Blocked |
| Reconfigure a property descriptor | Allowed | Blocked | Blocked |
| Reassign an existing writable property's value | Allowed | Allowed | Blocked |
const obj = { count: 1 };
Object.freeze(obj);
obj.count = 2; // silently ignored (throws in strict mode)
console.log(obj.count); // 1
Symbols as collision-free keys
A symbol is a primitive guaranteed unique — even two symbols created with the identical description are different values, useful specifically as an object key that can never accidentally collide with a string key or another symbol:
const s1 = Symbol('id');
const s2 = Symbol('id');
console.log(s1 === s2); // false — always, regardless of matching descriptions
const user = { name: 'Ada', [s1]: 42 };
console.log(Object.keys(user)); // ["name"] — symbol keys never enumerate this way
A well-known symbol is a specific, predefined symbol (like Symbol.iterator, already used in
Functional Techniques and Collection Design)
that hooks a user-defined object into built-in language behavior:
class TemperatureRange {
constructor(low, high) {
this.low = low;
this.high = high;
}
[Symbol.toPrimitive](hint) {
if (hint === 'string') return `${this.low}–${this.high}°`;
return this.high - this.low; // number hint (and default)
}
}
const range = new TemperatureRange(10, 20);
console.log(`Range: ${range}`); // "Range: 10–20°" — string hint
console.log(range + 5); // 15 — number/default hint, ToPrimitive from the previous lesson at work
Proxy and Reflect
A Proxy wraps a target object and intercepts fundamental operations (get, set, has,
deleteProperty, and others) through traps — functions that run instead of the default
behavior:
function createValidatedUser(target) {
return new Proxy(target, {
set(obj, prop, value) {
if (prop === 'age' && (typeof value !== 'number' || value < 0)) {
throw new TypeError('age must be a non-negative number');
}
return Reflect.set(obj, prop, value); // delegate to the default behavior
},
});
}
const user = createValidatedUser({ name: 'Ada', age: 30 });
user.age = 31; // fine
user.age = -5; // throws TypeErrorReflect provides the default implementation of each fundamental operation as a plain function —
Reflect.set(obj, prop, value) does exactly what a normal assignment would, which is why a trap
calls it to fall back to default behavior for everything the trap doesn’t specifically care about.
The real cost of a Proxy
A Proxy is genuinely powerful, but every trapped operation adds overhead invisible in ordinary
object access, and — more importantly — makes an object’s behavior harder to reason about by
inspection alone: console.log, a debugger, and a reader all now need to account for arbitrary
trap logic on what looks like a plain object.
Cross-realm identity (recall realm from the previous lesson) means
instanceof can fail for a genuinely-array value from a different realm; a brand check —
verifying an object was constructed by a specific class, using a private field’s mere presence as
proof — is more robust across that boundary than instanceof:
class Wallet {
#balance; // presence of this field is unforgeable, checkable via 'in' on the class itself
constructor(balance) {
this.#balance = balance;
}
static isWallet(obj) {
try {
return #balance in obj; // true only for genuine Wallet instances, any realm
} catch {
return false;
}
}
}
Check your understanding
Or reveal the answer without checking
Answer:2
Object.seal blocks adding/deleting/reconfiguring properties, but existing writable properties can still be reassigned. obj.count = 2 succeeds; delete obj.count is silently blocked (sealed, not frozen). The final value is 2.
Or reveal the answer without checking
Answer:A TypeError is thrown automatically — this specific invariant cannot be violated
Certain Proxy invariants are enforced by the specification regardless of trap logic — a get trap lying about a non-configurable, non-writable property's value is one of them, and violating it throws a TypeError automatically. This is what keeps a Proxy from being able to break guarantees the rest of the language depends on.
Practice: warm-up
Predict the output, then check:
const s1 = Symbol('key');
const obj = { [s1]: 'secret', visible: 'public' };
console.log(JSON.stringify(obj));
console.log(Object.getOwnPropertySymbols(obj).length);
Stuck? Reveal one hint at a time.
Hint 1
JSON.stringify only serializes string-keyed enumerable own properties — symbol keys are always skipped entirely.
Hint 2
Object.getOwnPropertySymbols specifically lists symbol keys, which is a different (and rarely-needed) inspection path from Object.keys.
Reveal the output
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
{"visible":"public"}
1Practice: apply it
Build a readOnly(target) function returning a Proxy that allows reading any property but throws
a TypeError on any attempted set.
Stuck? Reveal one hint at a time.
Hint 1
Only the set trap needs custom behavior — every other trap can be omitted, defaulting to normal behavior automatically.
Hint 2
Throwing directly inside the trap (rather than calling Reflect.set) is what actually prevents the write.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
function readOnly(target) {
return new Proxy(target, {
set() {
throw new TypeError('This object is read-only.');
},
});
}
const config = readOnly({ apiUrl: 'https://example.com' });
console.log(config.apiUrl); // reads fine
config.apiUrl = 'other'; // throws TypeErrorSummary
Object.freezeis the strictest integrity level (blocks add/delete/reconfigure/reassign) but is shallow — nested objects remain fully mutable unless frozen separately.- A symbol is a guaranteed-unique primitive; a well-known symbol (
Symbol.iterator,Symbol.toPrimitive) hooks a user object into built-in language behavior. Proxyintercepts fundamental operations via traps;Reflectprovides each operation’s default implementation, used inside a trap to fall back to normal behavior.- Certain
Proxyinvariants (like not lying about a non-configurable property) are enforced by the specification itself and cannot be overridden by trap logic. - A
Proxyadds real overhead and debugging cost — worth it specifically when intercepting arbitrary property access is the actual point, not as a default validation mechanism where a plain factory function with explicit methods would be simpler to reason about.