AdvancedDeep object and meta-programming semantics~45 min

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

Three integrity levels, and what each one actually locks down
OperationObject.preventExtensionsObject.sealObject.freeze
Add a new propertyBlockedBlockedBlocked
Delete an existing propertyAllowedBlockedBlocked
Reconfigure a property descriptorAllowedBlockedBlocked
Reassign an existing writable property's valueAllowedAllowedBlocked
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:

JavaScriptA validating Proxy, using Reflect for 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 TypeError

Reflect 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

const obj = Object.seal({ count: 1 }); obj.count = 2; delete obj.count; console.log(obj.count);
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.

A get trap on a Proxy tries to return a different value than the target's actual value for a non-configurable, non-writable property. What happens?
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.

  1. Hint 1

    JSON.stringify only serializes string-keyed enumerable own properties — symbol keys are always skipped entirely.

  2. 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"}
1

Practice: 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.

  1. Hint 1

    Only the set trap needs custom behavior — every other trap can be omitted, defaulting to normal behavior automatically.

  2. 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 TypeError

Summary

  • Object.freeze is 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.
  • Proxy intercepts fundamental operations via traps; Reflect provides each operation’s default implementation, used inside a trap to fall back to normal behavior.
  • Certain Proxy invariants (like not lying about a non-configurable property) are enforced by the specification itself and cannot be overridden by trap logic.
  • A Proxy adds 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.