Back to JavaScript and TypeScript

Proxies and the Reflect API

Proxy wraps any object and lets you intercept fundamental operations get, set, delete, in, new, and more. Reflect is its companion: a clean API for forwarding those operations correctly. Together they power Vue 3's reactivity, runtime validation, and observability tooling.

Core API

Core Traps

Traps are handler methods that fire when specific operations happen on the proxy. The most useful three are get, set, and has.

Creating a Proxy

new Proxy(target, handler) wraps an object. The handler is a plain object whose methods called *traps* intercept operations on the target. If a trap is missing, the operation passes through unchanged.

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

const handler = {
  get(target, prop) {
    console.log(`Getting: ${prop}`)
    return Reflect.get(target, prop)  // forward to target
  }
}

const proxy = new Proxy(target, handler)

proxy.name  // logs "Getting: name", returns "Alice"
proxy.age   // logs "Getting: age", returns 30

The set trap validation

The set trap fires before a property is written. Return true to allow the write, false (or throw) to reject it. This is how you implement runtime type checking or immutability.

function createTypedObject(schema) {
  return new Proxy({}, {
    set(target, prop, value) {
      const expected = schema[prop]
      if (expected && typeof value !== expected) {
        throw new TypeError(`${prop} must be ${expected}, got ${typeof value}`)
      }
      target[prop] = value
      return true
    }
  })
}

const user = createTypedObject({ name: "string", age: "number" })
user.name = "Bob"    // ok
user.age  = 25       // ok
user.age  = "old"    // throws TypeError

The has trap hiding properties

The has trap intercepts the in operator. You can make properties invisible to in checks, which also hides them from for...in loops.

const secrets = new Proxy(
  { public: "visible", _private: "hidden" },
  {
    has(target, prop) {
      if (prop.startsWith("_")) return false  // pretend it doesn't exist
      return Reflect.has(target, prop)
    }
  }
)

console.log("public"   in secrets)   // true
console.log("_private" in secrets)   // false  hidden
console.log(secrets._private)        // "hidden"  still readable directly

Available traps

  • get property read
  • set property write
  • has in operator
  • deleteProperty delete operator
  • apply function call
  • construct new operator
  • getPrototypeOf Object.getPrototypeOf
  • ownKeys Object.keys / for...in

Reflect

The Reflect API

Every Proxy trap has a corresponding Reflect method. Using Reflect inside traps is safer than direct property access it correctly passes the receiver argument, which matters for inherited getters.

Reflect mirrors every Proxy trap

Reflect has a static method for each Proxy trap. Using Reflect inside traps is the correct way to forward operations it handles edge cases like receiver objects correctly, unlike direct property access.

const handler = {
  get(target, prop, receiver) {
    // Reflect.get correctly handles getters with inheritance
    return Reflect.get(target, prop, receiver)
    // NOT: target[prop]   breaks getter context
  },
  set(target, prop, value, receiver) {
    return Reflect.set(target, prop, value, receiver)
  },
  deleteProperty(target, prop) {
    return Reflect.deleteProperty(target, prop)
  }
}

Patterns

Practical Patterns

These three patterns show up in real codebases and libraries. They all follow the same structure: wrap an object, intercept the relevant trap, do your thing, then forward with Reflect.

Reactive state (Vue 3 / Solid.js style)

Reactive state systems like Vue 3 and Solid use Proxy under the hood. The get trap tracks which signals read a property; the set trap notifies subscribers when it changes.

function reactive(obj) {
  const subscribers = new Map()

  return new Proxy(obj, {
    get(target, prop) {
      // Track: something is reading this property
      if (!subscribers.has(prop)) subscribers.set(prop, new Set())
      // (in real systems: record the current running effect)
      return Reflect.get(target, prop)
    },
    set(target, prop, value) {
      const result = Reflect.set(target, prop, value)
      // Notify: something changed
      subscribers.get(prop)?.forEach(fn => fn(value))
      return result
    }
  })
}

Logging / audit trail

Wrap any object to automatically log every read and write useful for debugging or building audit trails without modifying the original object.

function withAudit(obj, name = "object") {
  return new Proxy(obj, {
    get(target, prop) {
      const val = Reflect.get(target, prop)
      console.log(`[READ]  ${name}.${prop}${JSON.stringify(val)}`)
      return val
    },
    set(target, prop, value) {
      console.log(`[WRITE] ${name}.${prop} = ${JSON.stringify(value)}`)
      return Reflect.set(target, prop, value)
    }
  })
}

const config = withAudit({ debug: false, timeout: 5000 }, "config")
config.debug = true      // [WRITE] config.debug = true
config.timeout           // [READ]  config.timeout → 5000

Default values with a get trap

Return a fallback when a property is missing instead of undefined. This is like a Map with defaults, but works on plain objects and keeps normal property access syntax.

function withDefaults(target, defaults) {
  return new Proxy(target, {
    get(t, prop) {
      return Reflect.has(t, prop)
        ? Reflect.get(t, prop)
        : defaults[prop]
    }
  })
}

const config = withDefaults(
  { timeout: 3000 },
  { retries: 3, timeout: 5000, verbose: false }
)

console.log(config.timeout)  // 3000   own value
console.log(config.retries)  // 3      from defaults
console.log(config.verbose)  // false  from defaults