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