Skip to content
Codeloom
JavaScript

Proxy and Reflect API for JavaScript Metaprogramming

Learn how to use JavaScript Proxy and Reflect to intercept operations, build validators, create observable objects, and write metaprogramming patterns.

·6 min read · By Codeloom
Advanced 10 min read

What you'll learn

  • How Proxy traps intercept fundamental object operations
  • Using Reflect to forward default behavior cleanly
  • Building validation layers, observable state, and access control

Prerequisites

  • Solid JavaScript object fundamentals
  • Familiarity with getters and setters

JavaScript’s Proxy and Reflect APIs let you intercept and redefine fundamental operations on objects. Together they form the foundation of metaprogramming in JavaScript — building code that manipulates other code at runtime.

What is a Proxy?

A Proxy wraps a target object and lets you define traps — handler functions that intercept operations like property reads, writes, deletions, and function calls.

const target = { greeting: "hello" };

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

const proxy = new Proxy(target, handler);
console.log(proxy.greeting);
// "Accessing property: greeting"
// "hello"

The first argument to new Proxy() is the target object. The second is the handler containing your traps. When you interact with the proxy, the traps fire instead of the default behavior.

Why Reflect?

Reflect provides static methods that mirror every Proxy trap. Instead of manually doing target[prop], you call Reflect.get(target, prop, receiver). This is important because Reflect methods handle edge cases around prototype chains and receivers correctly.

const handler = {
  get(target, prop, receiver) {
    // Bad: target[prop] -- ignores receiver, breaks inheritance
    // Good: Reflect.get preserves correct this binding
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    console.log(`Setting ${prop} to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  },
  has(target, prop) {
    console.log(`Checking if "${prop}" exists`);
    return Reflect.has(target, prop);
  },
  deleteProperty(target, prop) {
    console.log(`Deleting ${prop}`);
    return Reflect.deleteProperty(target, prop);
  },
};

Every Reflect method returns a boolean or the value, making it easy to compose behavior on top of defaults.

Pattern 1: Validation layer

One of the most practical uses is building a validation layer that enforces types at runtime.

function createValidatedObject(schema) {
  return new Proxy(
    {},
    {
      set(target, prop, value, receiver) {
        const validator = schema[prop];
        if (!validator) {
          throw new Error(`Unknown property: ${prop}`);
        }
        if (!validator(value)) {
          throw new TypeError(
            `Invalid value for ${prop}: ${JSON.stringify(value)}`
          );
        }
        return Reflect.set(target, prop, value, receiver);
      },
    }
  );
}

const user = createValidatedObject({
  name: (v) => typeof v === "string" && v.length > 0,
  age: (v) => Number.isInteger(v) && v >= 0 && v <= 150,
  email: (v) => typeof v === "string" && v.includes("@"),
});

user.name = "Alice";   // works
user.age = 30;         // works
user.age = -5;         // TypeError: Invalid value for age: -5
user.status = "active"; // Error: Unknown property: status

Pattern 2: Observable / reactive state

Frameworks like Vue use Proxy internally to make state reactive. Here is a simplified version.

function reactive(obj, onChange) {
  return new Proxy(obj, {
    set(target, prop, value, receiver) {
      const oldValue = target[prop];
      const result = Reflect.set(target, prop, value, receiver);
      if (oldValue !== value) {
        onChange(prop, value, oldValue);
      }
      return result;
    },
    deleteProperty(target, prop) {
      const oldValue = target[prop];
      const result = Reflect.deleteProperty(target, prop);
      onChange(prop, undefined, oldValue);
      return result;
    },
  });
}

const state = reactive({ count: 0 }, (prop, newVal, oldVal) => {
  console.log(`${prop} changed: ${oldVal} -> ${newVal}`);
});

state.count = 1; // "count changed: 0 -> 1"
state.count = 2; // "count changed: 1 -> 2"

Pattern 3: Access control and read-only objects

You can create objects that refuse modification entirely.

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

const config = readOnly({ apiUrl: "https://api.example.com", timeout: 5000 });
console.log(config.apiUrl); // works
config.apiUrl = "hacked";   // Error: This object is read-only

Pattern 4: Default values and missing properties

Instead of returning undefined for missing properties, you can provide defaults.

function withDefaults(target, defaults) {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      }
      return defaults[prop];
    },
  });
}

const settings = withDefaults(
  { theme: "dark" },
  { theme: "light", fontSize: 16, language: "en" }
);

console.log(settings.theme);    // "dark" (from target)
console.log(settings.fontSize); // 16 (from defaults)

Pattern 5: Logging and profiling with the apply trap

The apply trap intercepts function calls when the proxy target is a function.

function profileFunction(fn, label) {
  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(`${label} took ${duration.toFixed(2)}ms`);
      return result;
    },
  });
}

const slowSort = profileFunction((arr) => [...arr].sort(), "sort");
slowSort([3, 1, 4, 1, 5, 9, 2, 6]); // "sort took 0.04ms"

The construct trap

You can also intercept new calls with the construct trap.

function singleton(TargetClass) {
  let instance = null;
  return new Proxy(TargetClass, {
    construct(target, args, newTarget) {
      if (!instance) {
        instance = Reflect.construct(target, args, newTarget);
      }
      return instance;
    },
  });
}

const SingleDB = singleton(
  class Database {
    constructor() {
      this.id = Math.random();
    }
  }
);

const a = new SingleDB();
const b = new SingleDB();
console.log(a === b); // true

Revocable proxies

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

const { proxy, revoke } = Proxy.revocable(
  { secret: "data" },
  {
    get(target, prop, receiver) {
      return Reflect.get(target, prop, receiver);
    },
  }
);

console.log(proxy.secret); // "data"
revoke();
// proxy.secret -> TypeError: Cannot perform 'get' on a revoked proxy

This is useful for granting temporary access to resources.

Performance considerations

Proxies add a layer of indirection. Every trapped operation goes through your handler function. For hot paths (tight loops over thousands of iterations), this overhead can matter. Measure before wrapping performance-critical objects.

Also, proxies are not transparent — proxy !== target. Code that relies on strict identity checks may break. Always test that downstream code works correctly with proxied objects.

Summary

  • Proxy intercepts 13 fundamental operations on objects via traps.
  • Reflect provides matching static methods that implement the default behavior of each trap.
  • Common patterns: validation, reactive state, access control, default values, logging, and singletons.
  • Use Proxy.revocable() for temporary access.
  • Be mindful of performance overhead on hot paths.

Proxy and Reflect unlock metaprogramming capabilities that were previously impossible in JavaScript. They are the foundation behind reactivity systems, ORMs, and validation libraries used across the ecosystem.