Skip to content
Codeloom
JavaScript

JavaScript Proxy and Reflect: Metaprogramming Guide

Master JavaScript Proxy and Reflect API for metaprogramming with practical examples including validation, observable objects, and access control patterns.

·7 min read · By Codeloom
Advanced 13 min read

What you'll learn

  • How Proxy traps intercept fundamental object operations
  • Why Reflect is the correct way to forward trapped operations
  • Practical patterns like validation, logging, and reactive objects
  • Performance implications and when not to use Proxy

Prerequisites

  • Strong JavaScript fundamentals
  • Understanding of objects and prototypes

Proxy and Reflect are JavaScript’s metaprogramming primitives. A Proxy wraps an object and lets you intercept and redefine fundamental operations like property access, assignment, function invocation, and more. Reflect provides methods that mirror those operations, giving you a clean way to perform the default behavior inside a trap. Together, they enable patterns that were previously impossible or required ugly hacks.

Proxy Fundamentals

A Proxy takes two arguments: the target object and a handler with trap methods:

const target = { name: "Alice", age: 30 };

const handler = {
  get(target, property, receiver) {
    console.log(`Reading ${property}`);
    return Reflect.get(target, property, receiver);
  },

  set(target, property, value, receiver) {
    console.log(`Setting ${property} to ${value}`);
    return Reflect.set(target, property, value, receiver);
  },
};

const proxy = new Proxy(target, handler);

proxy.name;       // logs "Reading name", returns "Alice"
proxy.age = 31;   // logs "Setting age to 31"

The proxy is transparent to external code. It looks and behaves like the target object, except the handler traps fire on every operation.

All Available Traps

There are 13 traps corresponding to internal object methods:

const fullHandler = {
  get(target, prop, receiver) {},
  set(target, prop, value, receiver) {},
  has(target, prop) {},
  deleteProperty(target, prop) {},
  ownKeys(target) {},
  getOwnPropertyDescriptor(target, prop) {},
  defineProperty(target, prop, descriptor) {},
  getPrototypeOf(target) {},
  setPrototypeOf(target, proto) {},
  isExtensible(target) {},
  preventExtensions(target) {},
  apply(target, thisArg, args) {},
  construct(target, args, newTarget) {},
};

The apply and construct traps only work when the target is a function. All other traps work with any object.

Why Reflect Matters

You might wonder why we use Reflect.get() instead of target[property]. The difference matters when the proxy is used as a prototype or when getters are involved:

const parent = {
  get greeting() {
    return `Hello, ${this.name}`;
  },
};

const child = Object.create(
  new Proxy(parent, {
    get(target, prop, receiver) {
      console.log(`Trapped: ${prop}`);
      return Reflect.get(target, prop, receiver);
    },
  })
);

child.name = "Bob";
console.log(child.greeting); // "Hello, Bob"

Reflect.get passes the receiver (which is child) through to the getter, so this.name resolves correctly. Using target[prop] would make this refer to the parent object, giving the wrong result.

Every Reflect method returns a boolean or value that indicates success, making it composable with conditional logic:

const handler = {
  set(target, prop, value, receiver) {
    const success = Reflect.set(target, prop, value, receiver);
    if (success) {
      notifyListeners(prop, value);
    }
    return success;
  },
};

Practical Pattern: Validation

One of the most useful Proxy applications is transparent input validation:

function createValidated(schema) {
  return new Proxy({}, {
    set(target, prop, value, receiver) {
      const rule = schema[prop];
      if (rule) {
        const error = rule(value);
        if (error) {
          throw new TypeError(`${prop}: ${error}`);
        }
      }
      return Reflect.set(target, prop, value, receiver);
    },
  });
}

const user = createValidated({
  name: v => {
    if (typeof v !== "string") return "must be a string";
    if (v.length < 2) return "must be at least 2 characters";
    return null;
  },
  age: v => {
    if (typeof v !== "number") return "must be a number";
    if (v < 0 || v > 150) return "must be between 0 and 150";
    return null;
  },
  email: v => {
    if (typeof v !== "string" || !v.includes("@")) return "must be a valid email";
    return null;
  },
});

user.name = "Alice";
user.age = 30;
user.email = "alice@example.com";
// user.age = -5;  // throws TypeError: age: must be between 0 and 150

Practical Pattern: Observable Objects

Create objects that notify listeners when properties change:

function createObservable(target) {
  const listeners = new Map();

  function on(prop, callback) {
    if (!listeners.has(prop)) {
      listeners.set(prop, new Set());
    }
    listeners.get(prop).add(callback);
    return () => listeners.get(prop).delete(callback);
  }

  const proxy = new Proxy(target, {
    set(target, prop, value, receiver) {
      const oldValue = target[prop];
      const result = Reflect.set(target, prop, value, receiver);

      if (result && oldValue !== value) {
        listeners.get(prop)?.forEach(cb => cb(value, oldValue));
        listeners.get("*")?.forEach(cb => cb(prop, value, oldValue));
      }

      return result;
    },
  });

  proxy[Symbol.for("on")] = on;
  return proxy;
}

const state = createObservable({ count: 0, name: "World" });

const unsubscribe = state[Symbol.for("on")]("count", (newVal, oldVal) => {
  console.log(`count changed: ${oldVal} -> ${newVal}`);
});

state.count = 1;  // logs "count changed: 0 -> 1"
state.count = 2;  // logs "count changed: 1 -> 2"
unsubscribe();

Practical Pattern: Negative Array Indexing

Python-style negative indexing for arrays:

function createNegativeArray(arr) {
  return new Proxy(arr, {
    get(target, prop, receiver) {
      const index = Number(prop);
      if (Number.isInteger(index) && index < 0) {
        return Reflect.get(target, target.length + index, receiver);
      }
      return Reflect.get(target, prop, receiver);
    },

    set(target, prop, value, receiver) {
      const index = Number(prop);
      if (Number.isInteger(index) && index < 0) {
        return Reflect.set(target, target.length + index, value, receiver);
      }
      return Reflect.set(target, prop, value, receiver);
    },
  });
}

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

Practical Pattern: Access Control

Restrict what properties can be read or modified:

function createReadonly(target) {
  return new Proxy(target, {
    set() {
      throw new TypeError("This object is read-only");
    },
    deleteProperty() {
      throw new TypeError("This object is read-only");
    },
    defineProperty() {
      throw new TypeError("This object is read-only");
    },
  });
}

function createPrivate(target, privateProps) {
  const privates = new Set(privateProps);

  return new Proxy(target, {
    get(target, prop, receiver) {
      if (privates.has(prop)) {
        throw new ReferenceError(`${prop} is private`);
      }
      return Reflect.get(target, prop, receiver);
    },

    has(target, prop) {
      if (privates.has(prop)) return false;
      return Reflect.has(target, prop);
    },

    ownKeys(target) {
      return Reflect.ownKeys(target).filter(k => !privates.has(k));
    },
  });
}

const config = createPrivate(
  { host: "localhost", port: 3000, secretKey: "abc123", dbPassword: "hunter2" },
  ["secretKey", "dbPassword"]
);

console.log(config.host);         // "localhost"
console.log(Object.keys(config)); // ["host", "port"]

Practical Pattern: Lazy Loading

Defer expensive property computation until first access:

function createLazy(initializer) {
  let target = null;
  let initialized = false;

  return new Proxy({}, {
    get(_, prop, receiver) {
      if (!initialized) {
        target = initializer();
        initialized = true;
      }
      return Reflect.get(target, prop, receiver);
    },
  });
}

const heavyModule = createLazy(() => {
  console.log("Initializing heavy module...");
  return {
    process(data) {
      return data.map(x => x * 2);
    },
    version: "2.0",
  };
});

console.log(heavyModule.version);
// logs "Initializing heavy module...", then "2.0"

console.log(heavyModule.version);
// just "2.0", no re-initialization

Revocable Proxies

Proxy.revocable() creates a proxy that can be permanently disabled:

function createTemporaryAccess(target, ttlMs) {
  const { proxy, revoke } = Proxy.revocable(target, {});

  setTimeout(() => {
    revoke();
    console.log("Access revoked");
  }, ttlMs);

  return proxy;
}

const tempConfig = createTemporaryAccess({ apiKey: "sk-123" }, 5000);
console.log(tempConfig.apiKey); // "sk-123"

// After 5 seconds, any access throws TypeError

This is useful for temporary access tokens, session-scoped data, or ensuring objects cannot be used after their lifecycle ends.

Function Proxies

When the target is a function, you get apply and construct traps:

function createTracedFunction(fn, name) {
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const start = performance.now();
      const result = Reflect.apply(target, thisArg, args);
      const duration = performance.now() - start;
      console.log(`${name}(${args.join(", ")}) took ${duration.toFixed(2)}ms`);
      return result;
    },
  });
}

const tracedSort = createTracedFunction(
  arr => [...arr].sort((a, b) => a - b),
  "sort"
);

tracedSort([3, 1, 4, 1, 5]);

Performance Considerations

Proxies add overhead to every trapped operation. A property access through a Proxy is roughly 5-10x slower than direct access. For most applications this is negligible, but in hot loops processing millions of items, it matters.

Guidelines:

  • Do not wrap objects that are accessed in tight loops
  • Use Proxies at boundaries (API layers, configuration, user-facing objects) rather than core data structures
  • Consider using Proxy only during development (validation, debugging) and stripping them in production
  • Cache derived values rather than computing them in traps
const users = new Array(1_000_000).fill(null).map((_, i) => ({
  id: i,
  name: `User ${i}`,
}));

// Slow: wrapping each item
const proxied = users.map(u => new Proxy(u, handler));

// Fast: wrap the collection, not individual items
const proxiedList = new Proxy(users, collectionHandler);

Wrapping Up

Proxy and Reflect are powerful metaprogramming tools that let you intercept and customize fundamental object operations. The most practical applications are transparent validation, observable state, access control, and lazy initialization. Always use Reflect methods inside traps to preserve correct this binding and prototype chain behavior. Be mindful of performance costs, and apply Proxies at architectural boundaries rather than on hot-path data structures.