Chapter 15

Proxy and Reflect: 13 Traps and Complete Metaprogramming

Proxy is the most powerful metaprogramming tool JavaScript has ever had. It lets you install hooks at the lowest level of any object operation — not just property reads and writes, but also the in operator, delete, new, function calls, prototype access, and all 13 fundamental object operations in total. Reflect is its mirror: for every Proxy trap, there is a Reflect method that provides the default behavior for that operation. Vue 3's entire reactivity system is built on these two APIs.

🔹 Level 1 · What You Need to Know

Basic Proxy Structure

const proxy = new Proxy(target, handler);
const target = { name: 'Alice', age: 30 };

const handler = {
  get(target, prop, receiver) {
    console.log(`reading property: ${prop}`);
    return Reflect.get(target, prop, receiver); // forward default behavior
  },
  set(target, prop, value, receiver) {
    console.log(`setting property: ${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const proxy = new Proxy(target, handler);

proxy.name;          // prints: reading property: name → 'Alice'
proxy.name = 'Bob';  // prints: setting property: name = Bob

The 4 Most Commonly Used Traps

get trap (intercepts property reads):

// Simplified Vue 3 reactivity system
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key);   // collect dependency
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key); // notify updates
      return result;
    }
  });
}

has trap (intercepts the in operator):

// Implementing "range" semantics: 5 in range(1, 10)
function range(min, max) {
  return new Proxy({}, {
    has(target, prop) {
      const num = Number(prop);
      return !isNaN(num) && num >= min && num <= max;
    }
  });
}

const r = range(1, 10);
console.log(5 in r);   // true
console.log(15 in r);  // false
console.log('a' in r); // false

deleteProperty trap (intercepts delete operator):

// Read-only protection
const readOnly = new Proxy({ x: 1 }, {
  deleteProperty(target, prop) {
    throw new Error(`Cannot delete property: ${prop}`);
  },
  set(target, prop, value) {
    throw new Error(`Cannot modify property: ${prop}`);
  }
});

apply trap (intercepts function calls):

// Function call logging
function logged(fn) {
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      console.log(`calling ${fn.name}(${args.join(', ')})`);
      const result = Reflect.apply(target, thisArg, args);
      console.log(`returned: ${result}`);
      return result;
    }
  });
}

const add = logged((a, b) => a + b);
add(3, 4); // prints: calling (3, 4) → returned: 7

What Reflect Is For

Every Reflect method corresponds to a Proxy trap, letting you execute that operation's default behavior:

// Use Reflect in traps to forward default behavior
const handler = {
  get(target, key, receiver) {
    // custom logic...
    return Reflect.get(target, key, receiver); // forward
  }
};

// Key difference between Reflect and Object methods:
// Reflect.defineProperty returns boolean (no throw on failure)
// Object.defineProperty returns the object or throws TypeError

Reflect.defineProperty({}, 'x', { value: 1 }); // true (success)
Reflect.defineProperty(Object.freeze({}), 'x', { value: 1 }); // false (failure, no throw)

5 Common Mistakes

Mistake 1: Operating on target directly in traps instead of using Reflect

// Dangerous: causes incorrect this binding
const handler = {
  get(target, key, receiver) {
    return target[key]; // wrong! uses target instead of receiver
  }
};

// Consider this case:
const proto = new Proxy({}, {
  get(target, key, receiver) {
    return target[key]; // if a child object accesses an inherited property,
                        // this will point to proto, not child
  }
});

const child = Object.create(proto);

// Correct approach:
const handler2 = {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver); // receiver properly forwarded
  }
};

Mistake 2: get trap returns a wrong value (violates invariant)

const target = {};
Object.defineProperty(target, 'x', { value: 42, writable: false, configurable: false });

const proxy = new Proxy(target, {
  get(target, key) {
    return 99; // violates invariant!
  }
});

proxy.x; // TypeError: 'get' on proxy: property 'x' is a read-only and non-configurable...
// Spec requires: for writable:false, configurable:false properties,
// the get trap must return the actual value

Mistake 3: set trap forgetting to return true

const proxy = new Proxy({}, {
  set(target, key, value) {
    target[key] = value;
    // forgot to return true!
  }
});

proxy.x = 1; // TypeError in strict mode: 'set' on proxy returned false

Mistake 4: Proxying a Proxy object (nested proxies trigger traps twice)

const inner = new Proxy({}, { get(t, k) { console.log('inner get', k); return t[k]; } });
const outer = new Proxy(inner, { get(t, k) { console.log('outer get', k); return t[k]; } });

outer.x;
// Output: outer get x (outer intercepts)
//         inner get x (outer uses t[k], triggering inner's trap!)
// Using Reflect.get(t, k, receiver) instead would only trigger once

Mistake 5: Using a revoked Proxy

const { proxy, revoke } = Proxy.revocable({ x: 1 }, {});
proxy.x; // 1
revoke();
proxy.x; // TypeError: Cannot perform 'get' on a proxy that has been revoked

🔸 Level 2 · How It Works Internally

All 13 Traps

Trap Internal Method Intercepted Triggered By
get [[Get]] property reads, prototype chain lookup
set [[Set]] property assignment
has [[HasProperty]] in operator
deleteProperty [[Delete]] delete operator
apply [[Call]] function call fn()
construct [[Construct]] new operator
getPrototypeOf [[GetPrototypeOf]] Object.getPrototypeOf, instanceof, __proto__
setPrototypeOf [[SetPrototypeOf]] Object.setPrototypeOf, __proto__ =
isExtensible [[IsExtensible]] Object.isExtensible
preventExtensions [[PreventExtensions]] Object.preventExtensions/seal/freeze
getOwnPropertyDescriptor [[GetOwnProperty]] Object.getOwnPropertyDescriptor
defineProperty [[DefineOwnProperty]] Object.defineProperty, property creation
ownKeys [[OwnPropertyKeys]] Object.keys/values/entries, for...in, Object.getOwnPropertyNames/Symbols

Vue 3 Reactivity Core in ~40 Lines

A fully functional implementation of Vue 3's reactive core:

// Global storage: WeakMap<object, Map<key, Set<effect>>>
const targetMap = new WeakMap();
let activeEffect = null; // currently running effect

// Track dependency
function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));

  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));

  dep.add(activeEffect);
}

// Trigger updates
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const effects = depsMap.get(key);
  if (effects) effects.forEach(effect => effect());
}

// Create a reactive object
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);
      track(target, key); // collect dependency on read
      // recursively proxy nested objects
      if (typeof value === 'object' && value !== null) {
        return reactive(value);
      }
      return value;
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key); // notify updates on write
      return result;
    }
  });
}

// Register an effect (side-effect function)
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();             // execute → triggers dependency collection
    activeEffect = null;
  };
  effectFn();         // run once immediately
  return effectFn;
}

// Usage:
const state = reactive({ count: 0, name: 'Vue' });

effect(() => {
  console.log(`count is ${state.count}`);
  // executing reads count → track(state, 'count') → adds this effect to deps
});

state.count++; // trigger(state, 'count') → re-runs effect → prints "count is 1"
state.name = 'Vue 3'; // trigger(state, 'name') → no subscribers, no output

The Invariant System

The spec constrains every Proxy trap's return value. Violations throw TypeError — this is a security mechanism:

Key invariants per trap:

get trap:
  If a property on target is writable:false AND configurable:false
  → get must return the same value as target's actual value

set trap:
  If a property on target is writable:false AND configurable:false
  → set cannot succeed (must return false, otherwise TypeError)

has trap:
  If a property on target is configurable:false
  → has must return true (cannot pretend property doesn't exist)
  If target is non-extensible and property doesn't exist
  → has must return false

deleteProperty trap:
  If a property on target is configurable:false
  → deleteProperty cannot return true

ownKeys trap:
  If target is non-extensible
  → ownKeys must include all of target's own property keys,
    no extra keys allowed
  All configurable:false property keys must appear in the result

getPrototypeOf trap:
  If target is non-extensible
  → must return the same value as target's actual prototype

isExtensible trap:
  Return value must match target's actual extensible state
  (strictest invariant — cannot lie about this)

🔺 Level 3 · How the Spec Defines It

Spec §10.5: Proxy Object Internal Methods

Spec §10.5.8 defines Proxy [[Get]], which typifies the structure of all traps:

Proxy [[Get]] (P, Receiver) (§10.5.8):

1. handler ← O.[[ProxyHandler]]
   If handler is null → TypeError (proxy has been revoked)

2. target ← O.[[ProxyTarget]]

3. trap ← GetMethod(handler, "get")
   If trap is undefined → return target.[[Get]](P, Receiver)
   (no trap → use default behavior)

4. trapResult ← Call(trap, handler, [target, P, Receiver])
   (invoke the trap function)

5. Invariant check:
   targetDesc ← target.[[GetOwnProperty]](P)
   If targetDesc is not undefined:
     If IsDataDescriptor(targetDesc):
       If targetDesc.[[Configurable]] is false
          AND targetDesc.[[Writable]] is false:
         If SameValue(trapResult, targetDesc.[[Value]]) is false
           → TypeError (cannot return different value for non-configurable, non-writable property)
     If IsAccessorDescriptor(targetDesc):
       If targetDesc.[[Configurable]] is false
          AND targetDesc.[[Get]] is undefined:
         If trapResult is not undefined
           → TypeError (accessor with undefined [[Get]] must return undefined)

6. Return trapResult

The Precise Invariants of the ownKeys Trap

The ownKeys trap has the most complex invariants of all (§10.5.11):

Spec constraints (Proxy [[OwnPropertyKeys]]):

Let trapResultKeys = the list returned by the trap
Let targetKeys = target.[[OwnPropertyKeys]]()

Constraint 1: trapResultKeys has no duplicate keys
Constraint 2: every element of trapResultKeys is a String or Symbol

If target is non-extensible:
  Constraint 3: trapResultKeys must contain all elements of targetKeys
  Constraint 4: every element of targetKeys must appear in trapResultKeys exactly once

If target is extensible:
  Constraint 5: all configurable:false properties in targetKeys must appear in trapResultKeys
// Demonstrating invariant violation:
const target = Object.freeze({ x: 1, y: 2 }); // non-extensible

const proxy = new Proxy(target, {
  ownKeys() {
    return ['x']; // missing 'y' — violates constraint 3
  }
});

Object.keys(proxy); // TypeError: 'ownKeys' on proxy: trap result did not include 'y'

// Correct use of ownKeys (filtering enumerable properties):
const proxy2 = new Proxy({ a: 1, _b: 2, c: 3 }, {
  ownKeys(target) {
    // only expose keys not starting with _
    return Reflect.ownKeys(target).filter(k => !String(k).startsWith('_'));
  },
  getOwnPropertyDescriptor(target, key) {
    // must also override getOwnPropertyDescriptor, otherwise Object.keys
    // will filter out these keys (they need to be enumerable own properties)
    if (!String(key).startsWith('_')) {
      return { ...Object.getOwnPropertyDescriptor(target, key), enumerable: true };
    }
    return undefined;
  }
});

Object.keys(proxy2); // ['a', 'c']

defineProperty Trap Invariants

Spec constraints (Proxy [[DefineOwnProperty]]):

Constraint 1: if target is non-extensible, trap cannot return true to add non-existent property

Constraint 2: if target has a configurable:false property,
  trap cannot return true to make disallowed changes:
  - cannot change configurable to true
  - cannot change enumerable
  - if writable:false, cannot change value (unless SameValue)
  - cannot change writable from false to true

Constraint 3: if target has configurable:false and writable:false property,
  cannot modify it in any way

💎 Level 4 · Edge Cases and Traps

Trap 1: Proxy Cannot Wrap Primitive Values

new Proxy(42, {});     // TypeError: Cannot create proxy with a non-object as target
new Proxy(null, {});   // TypeError
new Proxy('str', {});  // TypeError
new Proxy(true, {});   // TypeError

// Can only proxy objects (including functions):
new Proxy({}, {});          // OK
new Proxy([], {});          // OK
new Proxy(function(){}, {}); // OK
new Proxy(class {}, {});    // OK

// Why? Proxy intercepts internal methods ([[Get]], [[Set]], etc.)
// Primitives have no internal methods — there's nothing to intercept

Trap 2: Precise Trigger Conditions for get Trap Invariant Violations

This is the trap that most easily produces TypeError. Full derivation:

const target = {};
Object.defineProperty(target, 'frozen', {
  value: 42,
  writable: false,
  configurable: false
});
Object.defineProperty(target, 'accessorFrozen', {
  get: undefined,  // getter is undefined
  configurable: false
});

const proxy = new Proxy(target, {
  get(target, key) {
    if (key === 'frozen') return 99;        // violates invariant!
    if (key === 'accessorFrozen') return 1; // violates invariant!
                                            // (getter is undefined → must return undefined)
    return target[key];
  }
});

proxy.frozen;         // TypeError: cannot return different value for non-writable, non-configurable property
proxy.accessorFrozen; // TypeError: accessor's [[Get]] is undefined — must return undefined

// Correct: use Reflect.get which handles invariants automatically
const proxy2 = new Proxy(target, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);
  }
});
proxy2.frozen; // 42 — correct

Trap 3: Proxy of a Proxy Triggers Traps Twice

let getCount = 0;

const inner = new Proxy({ x: 1 }, {
  get(target, key, receiver) {
    getCount++;
    console.log(`inner get #${getCount}: ${key}`);
    return Reflect.get(target, key, receiver);
  }
});

const outer = new Proxy(inner, {
  get(target, key, receiver) {
    getCount++;
    console.log(`outer get #${getCount}: ${key}`);
    return target[key]; // wrong! target is inner, target[key] fires inner's get trap
  }
});

outer.x;
// Output:
// outer get #1: x
// inner get #2: x

// Using Reflect.get(target, key, receiver) instead of target[key]
// still fires twice! Because receiver is outer, and Reflect.get forwards to inner

// To avoid double-firing in outer, directly operate on innerTarget:
const innerTarget = { x: 1 };
// But usually, nested proxies are intentionally designed to fire both traps

Legitimate use case for nested proxies:

// outer: logging
// inner: validation
// Both should fire — this is intentional

const validated = new Proxy({ x: 1 }, {
  set(target, key, value) {
    if (typeof value !== 'number') throw new TypeError('Only numbers allowed');
    return Reflect.set(target, key, value);
  }
});

const logged = new Proxy(validated, {
  set(target, key, value, receiver) {
    console.log(`Setting ${key} = ${value}`);
    return Reflect.set(target, key, value, receiver); // propagates to validated's set
  }
});

logged.x = 2;    // prints log + passes validation
logged.x = 'a'; // prints log + validation throws TypeError

Trap 4: Proxy.revocable in Detail

const { proxy, revoke } = Proxy.revocable({ x: 1, y: 2 }, {
  get(target, key) {
    return Reflect.get(target, key);
  }
});

// Use the proxy
console.log(proxy.x); // 1

// Revoke it
revoke();

// Any operation now throws:
proxy.x;            // TypeError: Cannot perform 'get' on a proxy that has been revoked
proxy.x = 1;        // TypeError
delete proxy.x;     // TypeError
'x' in proxy;       // TypeError
Object.keys(proxy); // TypeError

// revoke is idempotent:
revoke(); // no error

// Use case: temporary access permissions
function withTemporaryAccess(sensitiveObj, operation) {
  const { proxy, revoke } = Proxy.revocable(sensitiveObj, {});
  try {
    return operation(proxy);
  } finally {
    revoke(); // access revoked no matter what
  }
}

const secret = { token: 'secret-value' };
withTemporaryAccess(secret, (p) => {
  console.log(p.token); // 'secret-value'
  // after operation completes, proxy is automatically revoked
});

Trap 5: Exact Differences Between Reflect and Object Methods

Operation Reflect version Object version Key difference
Define property Reflect.defineProperty(obj, key, desc) → boolean Object.defineProperty(obj, key, desc) → obj or TypeError Reflect returns false on failure; Object throws
Delete property Reflect.deleteProperty(obj, key) → boolean delete obj.key → boolean (non-strict) or TypeError (strict + configurable:false) Reflect is more consistent
Check property Reflect.has(obj, key) → boolean key in obj → boolean Functionally identical
Get prototype Reflect.getPrototypeOf(obj) Object.getPrototypeOf(obj) For non-objects: Reflect throws TypeError; modern Object also throws
Prevent extensions Reflect.preventExtensions(obj) → boolean Object.preventExtensions(obj) → obj Different return types
Get all own keys Reflect.ownKeys(obj) Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)) Reflect.ownKeys includes all (String + Symbol), in spec order
// Important: error handling of Reflect.defineProperty vs Object.defineProperty
const frozen = Object.freeze({ x: 1 });

// Object version: throws
try {
  Object.defineProperty(frozen, 'y', { value: 2 });
} catch (e) {
  console.log('Object.defineProperty threw:', e.message);
}

// Reflect version: returns false
const success = Reflect.defineProperty(frozen, 'y', { value: 2 });
console.log('Reflect.defineProperty returned:', success); // false

// Inside Proxy traps, Reflect is better:
const proxy = new Proxy({}, {
  defineProperty(target, key, desc) {
    // With Reflect: failure returns false, Proxy layer handles it correctly
    return Reflect.defineProperty(target, key, desc);
    // With Object.defineProperty: exception might propagate to the wrong call stack
  }
});

Trap 6: "Impossible" Features Enabled by Proxy

Negative array indices (Python-style arr[-1]):

function negativeable(arr) {
  return new Proxy(arr, {
    get(target, key, receiver) {
      const index = Number(key);
      if (!isNaN(index) && index < 0) {
        key = String(target.length + index);
      }
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const index = Number(key);
      if (!isNaN(index) && index < 0) {
        key = String(target.length + index);
      }
      return Reflect.set(target, key, value, receiver);
    }
  });
}

const arr = negativeable([1, 2, 3, 4, 5]);
console.log(arr[-1]); // 5
console.log(arr[-2]); // 4
arr[-1] = 99;
console.log(arr);     // [1, 2, 3, 4, 99]

Deep read-only objects (truly recursive immutability):

function deepReadOnly(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);
      if (typeof value === 'object' && value !== null) {
        return deepReadOnly(value); // recursively proxy
      }
      return value;
    },
    set(target, key, value) {
      throw new TypeError(`Cannot set property '${String(key)}' on read-only object`);
    },
    deleteProperty(target, key) {
      throw new TypeError(`Cannot delete property '${String(key)}' on read-only object`);
    }
  });
}

const config = deepReadOnly({
  database: { host: 'localhost', port: 5432 },
  app: { port: 3000 }
});

config.database.host;          // 'localhost' — readable
config.database.host = 'new';  // TypeError
config.database.port = 9999;   // TypeError
delete config.app.port;        // TypeError

Auto-populating default values (auto-create nested paths):

function autoDefault(obj = {}) {
  return new Proxy(obj, {
    get(target, key) {
      if (!(key in target)) {
        target[key] = autoDefault(); // auto-create child object
      }
      return target[key];
    }
  });
}

const config = autoDefault();
config.database.host = 'localhost'; // auto-creates config.database
config.database.port = 5432;
config.app.server.port = 3000;      // auto-creates config.app, config.app.server

console.log(config.database.host);   // 'localhost'
console.log(config.app.server.port); // 3000

Runtime type safety (runtime type checking):

function typed(obj, schema) {
  return new Proxy(obj, {
    set(target, key, value) {
      if (key in schema) {
        const expectedType = schema[key];
        if (typeof value !== expectedType) {
          throw new TypeError(
            `Property '${key}' must be ${expectedType}, got ${typeof value}`
          );
        }
      }
      return Reflect.set(target, key, value);
    }
  });
}

const user = typed({}, {
  name: 'string',
  age: 'number',
  active: 'boolean'
});

user.name = 'Alice';  // OK
user.age = 30;        // OK
user.age = '30';      // TypeError: Property 'age' must be number, got string

Chapter Summary

  1. Proxy intercepts all 13 object operations, covering every internal method: from property reads and writes (get/set) to prototype access (getPrototypeOf), from key enumeration (ownKeys) to function invocation (apply/construct). Each trap corresponds to exactly one internal method.

  2. Reflect is the default implementation for every Proxy trap: calling Reflect.xxx(target, ...) inside a trap forwards default behavior while correctly passing the receiver (this value). Key distinction: Reflect methods return booleans; corresponding Object methods return objects or throw.

  3. Invariants are hard spec constraints on trap return values: no matter what a trap implements, the engine validates the return. For non-writable, non-configurable properties, get must return the actual value; isExtensible must return a value consistent with the target.

  4. Nested Proxies fire traps multiple times: when an outer proxy accesses an inner proxy using target[key] instead of operating on the underlying target, the inner trap also fires. Proxy.revocable creates a revocable proxy; after revocation, any operation throws TypeError.

  5. Proxy enables true metaprogramming: negative array indices, deep read-only objects, auto-default values, runtime type checking — all of these required large amounts of boilerplate code without Proxy and now take a dozen lines. Vue 3's entire reactivity system is built on get + set traps.

Rate this chapter
4.8  / 5  (19 ratings)

💬 Comments