Senior0 questionsFull Guide

JavaScript Proxy & Reflect Interview Questions

Proxy and Reflect enable JavaScript metaprogramming. A senior-level topic that shows deep language knowledge.

The Mental Model

Picture a personal assistant who sits between you and your boss. Every request that goes to the boss — emails, phone calls, meeting requests — passes through the assistant first. The assistant can forward the request unchanged, modify it, block it entirely, log it, or even handle it themselves without involving the boss at all. The boss never knows the difference. A Proxy is that assistant. You create a Proxy that wraps a target object. Any operation on the Proxy — reading a property, setting a value, calling a function, checking if a key exists — gets intercepted by handler functions called traps. You decide what happens inside each trap: pass it through to the real object, transform it, throw an error, or fabricate a response entirely. Reflect is the assistant's manual — a collection of functions that perform the default JavaScript operation for each trap. Reflect.get(target, key) does exactly what target[key] would normally do. Reflect.set(target, key, value) does what target[key] = value would do. This means inside a trap, you can do your custom work and then call the equivalent Reflect method to finish with normal behavior — without worrying about edge cases you'd introduce by accessing the target directly.

The Explanation

Creating a Proxy

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

const handler = {
  get(target, key, receiver) {
    console.log(`Reading: ${key}`)
    return Reflect.get(target, key, receiver)  // default behavior
  },
  set(target, key, value, receiver) {
    console.log(`Writing: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)

proxy.name        // logs "Reading: name" → 'Alice'
proxy.age = 31    // logs "Writing: age = 31"
proxy.name        // 'Alice' — target was modified

// The proxy IS the interface — callers use proxy, not target
// target and proxy stay in sync because Reflect passes operations through

All 13 traps — what each intercepts

const fullHandler = {
  get(target, key, receiver) {},           // target[key], target.key
  set(target, key, value, receiver) {},    // target[key] = value
  has(target, key) {},                     // key in target
  deleteProperty(target, key) {},          // delete target[key]
  apply(target, thisArg, args) {},         // target(...args) — function calls
  construct(target, args, newTarget) {},   // new target(...args)
  getPrototypeOf(target) {},               // Object.getPrototypeOf(target)
  setPrototypeOf(target, proto) {},        // Object.setPrototypeOf(target, proto)
  isExtensible(target) {},                 // Object.isExtensible(target)
  preventExtensions(target) {},            // Object.preventExtensions(target)
  getOwnPropertyDescriptor(target, key){}, // Object.getOwnPropertyDescriptor(target, key)
  defineProperty(target, key, desc) {},    // Object.defineProperty(target, key, desc)
  ownKeys(target) {},                      // Object.keys(), for...in, Object.getOwnPropertyNames()
}

Validation — the most practical use case

function createTypedObject(schema) {
  return new Proxy({}, {
    set(target, key, value) {
      if (!(key in schema)) {
        throw new TypeError(`Unknown property: ${key}`)
      }
      if (typeof value !== schema[key]) {
        throw new TypeError(`${key} must be ${schema[key]}, got ${typeof value}`)
      }
      return Reflect.set(target, key, value)
    }
  })
}

const user = createTypedObject({ name: 'string', age: 'number' })
user.name = 'Alice'   // ✓
user.age  = 30        // ✓
user.age  = '30'      // ✗ TypeError: age must be number, got string
user.role = 'admin'   // ✗ TypeError: Unknown property: role

Default values — the get trap

// Return a default value for missing properties instead of undefined
const withDefaults = new Proxy({}, {
  get(target, key) {
    return key in target ? target[key] : `[missing: ${key}]`
  }
})

withDefaults.name   // '[missing: name]'
withDefaults.x = 1
withDefaults.x      // 1

// Deeply nested access without null checks:
function deepProxy(obj = {}) {
  return new Proxy(obj, {
    get(target, key) {
      if (key in target) return target[key]
      return deepProxy()  // return another proxy for chaining
    }
  })
}

const config = deepProxy({ db: { host: 'localhost' } })
config.db.host         // 'localhost'
config.db.port         // Proxy {} — no error, keeps chaining
config.missing.deeply.nested  // Proxy {} — never throws

Logging and debugging — the observability trap

function createObservable(target, onChange) {
  return new Proxy(target, {
    set(target, key, value) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value)
      if (oldValue !== value) {
        onChange({ key, oldValue, newValue: value })
      }
      return result
    },
    deleteProperty(target, key) {
      const oldValue = target[key]
      const result = Reflect.deleteProperty(target, key)
      onChange({ key, oldValue, newValue: undefined, deleted: true })
      return result
    }
  })
}

const state = createObservable({ count: 0 }, change => {
  console.log('State changed:', change)
})

state.count = 1   // State changed: { key: 'count', oldValue: 0, newValue: 1 }
state.count = 1   // No log — value didn't change
delete state.count // State changed: { key: 'count', oldValue: 1, deleted: true }

The apply trap — intercepting function calls

// Wrap any function with logging, caching, rate-limiting, etc.
function memoize(fn) {
  const cache = new Map()
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args)
      if (cache.has(key)) {
        console.log('cache hit:', key)
        return cache.get(key)
      }
      const result = Reflect.apply(target, thisArg, args)
      cache.set(key, result)
      return result
    }
  })
}

function expensiveCalc(n) {
  return n * n  // imagine this takes 1 second
}

const memoized = memoize(expensiveCalc)
memoized(5)   // computes, caches
memoized(5)   // 'cache hit: [5]' — returns instantly
memoized(10)  // computes, caches

Reflect — why it exists alongside Proxy

Reflect provides a function form of every fundamental object operation. Before Reflect, some operations were only available as syntax (in, delete, new) which can't be delegated programmatically. Reflect makes them first-class functions.

// Reflect mirrors every Proxy trap exactly — one Reflect method per trap
Reflect.get(target, 'name')                  // target['name']
Reflect.set(target, 'name', 'Bob')           // target['name'] = 'Bob'
Reflect.has(target, 'name')                  // 'name' in target
Reflect.deleteProperty(target, 'name')       // delete target['name']
Reflect.apply(fn, thisArg, args)             // fn.apply(thisArg, args)
Reflect.construct(Constructor, args)         // new Constructor(...args)
Reflect.ownKeys(target)                      // all own keys including symbols

// Reflect.set returns boolean (success/failure) — Object assignment throws or fails silently
// Reflect.defineProperty returns boolean — Object.defineProperty throws on failure
// Consistent return values make error handling cleaner in traps

// The receiver parameter — critical for correct prototype behavior
const proto = {
  get value() { return this._value }
}
const child = Object.create(proto)
child._value = 42

// Without receiver — 'this' in the getter refers to proto, not child:
Reflect.get(proto, 'value')              // undefined (_value not on proto)
// With receiver — 'this' is child:
Reflect.get(proto, 'value', child)       // 42 ← correct behavior

Revocable proxies — controlled access

const { proxy, revoke } = Proxy.revocable({ secret: 'password123' }, {
  get(target, key) {
    return Reflect.get(target, key)
  }
})

proxy.secret  // 'password123'

// After access window expires:
revoke()

proxy.secret  // TypeError: Cannot perform 'get' on a proxy that has been revoked
// The object becomes inaccessible — even the reference is neutered

// Use case: grant temporary access to a resource
function grantTemporaryAccess(resource, ms) {
  const { proxy, revoke } = Proxy.revocable(resource, {})
  setTimeout(revoke, ms)
  return proxy
}

Proxy limitations

// 1. Built-in objects with internal slots need special handling
// Map, Set, Date, Promise use internal slots that Proxy can't intercept directly
const mapProxy = new Proxy(new Map(), {})
mapProxy.set('key', 'value')  // TypeError: Method Map.prototype.set called on incompatible receiver
// Fix: bind the target's methods
const mapProxy2 = new Proxy(new Map(), {
  get(target, key) {
    const value = Reflect.get(target, key)
    return typeof value === 'function' ? value.bind(target) : value
  }
})
mapProxy2.set('key', 'value')  // works

// 2. Proxies have performance overhead — every operation goes through the trap
// Don't wrap hot paths in proxies in performance-critical code

// 3. Identity — proxy !== target
const target = {}
const proxy  = new Proxy(target, {})
proxy  === target   // false — different references
proxy  instanceof Object  // true — prototype chain is proxied through

Common Misconceptions

⚠️

Many devs think Proxy and Reflect are a paired requirement — but actually they're independent. You can use Proxy without Reflect (by directly manipulating the target), and you can use Reflect without Proxy (as a cleaner API for object operations). They're designed to complement each other, but neither requires the other. Reflect just makes writing correct Proxy traps easier by providing the default behavior as a first-class function.

⚠️

Many devs think Proxy intercepts operations on the target object directly — but actually Proxy only intercepts operations performed on the proxy reference itself. If code retains a direct reference to the target and operates on that, the traps never fire. This is why you must discard the target reference and only expose the proxy — otherwise the interception can be bypassed entirely.

⚠️

Many devs think the get trap only fires for properties that exist on the object — but actually the get trap fires for every property access on the proxy, whether the property exists or not. This is what makes default-value patterns and auto-vivification possible. The trap fires before JavaScript even checks if the property is defined.

⚠️

Many devs think Proxy has no performance cost — but actually every intercepted operation goes through a JavaScript function call instead of the engine's native property lookup. In tight loops or high-frequency property access, Proxy overhead is measurable. Vue 3's benchmarks showed that careful trap design and caching track results are necessary to make a Proxy-based reactivity system competitive with direct property access.

⚠️

Many devs think Proxy can be used transparently with all built-in types — but actually built-ins like Map, Set, Date, and WeakMap use internal slots ([[MapData]], [[DateValue]]) that are accessed by their methods. When those methods are called on a Proxy, the internal slot lookup fails because the Proxy itself doesn't have the slot. The target does. The fix is binding methods to the target, which is non-trivial and must be handled carefully.

⚠️

Many devs think the has trap only fires for the 'in' operator on own properties — but actually the has trap fires for all 'in' checks including prototype chain traversal. When JavaScript evaluates 'key' in proxy, it calls the has trap. This means you can make a proxy appear to have (or not have) any property for any 'in' check, which affects things like for...in loops and certain framework checks.

Where You'll See This in Real Code

Vue 3's entire reactivity system is built on Proxy — when you call reactive(obj), Vue wraps it in a Proxy with get and set traps. The get trap registers which computed property or component is currently rendering as a dependency. The set trap notifies all registered dependents to re-render. This replaced Vue 2's Object.defineProperty approach, which couldn't detect property addition or array index changes. Every ref(), reactive(), and computed() in Vue 3 is Proxy underneath.

Immer's produce() function — used by Redux Toolkit — uses Proxy to create draft objects. When you write draft.count++ inside produce(), the set trap records the mutation as a patch rather than applying it. After the recipe function completes, Immer applies all recorded patches to a structural clone of the original state, producing an immutable update. The user writes mutating code; Proxy makes it produce immutable results.

MobX's observable() wraps objects in Proxies that track every get and set. When a reaction or computed value is evaluating, MobX records every get trap that fires as a dependency. When any of those properties are later set, MobX re-runs the dependent reactions. The entire MobX reactive graph — the feature that makes React components automatically re-render on state changes — is Proxy-based property interception.

GraphQL client libraries use Proxy for lazy field selection — some clients wrap query results in Proxies that track which fields you actually access in your component, then use that access log to generate optimized queries on subsequent fetches. This "automatic query optimization" is only possible because Proxy's get trap fires for every field access, giving the library visibility into exactly which data the component actually needs.

JavaScript testing frameworks like Sinon and Jest use Proxy (or Object.defineProperty) to create spies and stubs. When you spy on an object method, the framework replaces it with a Proxy (or wrapped function) that records calls, arguments, and return values. The apply trap is what makes function call interception possible without the caller knowing anything changed.

Node.js's vm module uses Proxy for sandbox contexts — when you evaluate code in a vm.Context, the global object inside that sandbox is a Proxy that can intercept and restrict which globals are accessible. Security-sensitive platforms that run user code (like CodeSandbox, RunKit, or browser-based REPLs) use Proxy-wrapped globals to prevent sandbox escapes.

Interview Cheat Sheet

  • Proxy(target, handler): wraps target, intercepts operations via handler traps
  • 13 traps: get, set, has, deleteProperty, apply, construct, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, getOwnPropertyDescriptor, defineProperty, ownKeys
  • Reflect: function form of every built-in object operation — one per trap
  • Always use Reflect inside traps to preserve default behavior and correct receiver
  • receiver parameter: the object the operation was called on (may differ from target for prototype access)
  • Proxy.revocable(): returns { proxy, revoke } — revoked proxy throws on any access
  • Built-in slots problem: Map/Set/Date methods must be bound to target, not proxy
  • Proxy !== target: identity check fails — never leak the target reference
  • Vue 3, Immer, MobX — all Proxy-based at their core
💡

How to Answer in an Interview

  • 1.Vue 3's reactivity system is the killer real-world example — always mention it
  • 2.The receiver parameter question separates shallow Proxy knowledge from deep understanding
  • 3.Implement a validation proxy live — it's the cleanest demonstrable use case in ~10 lines
  • 4.The built-in slots limitation shows you've actually used Proxy in non-trivial code
  • 5.Proxy.revocable() for temporary access is a clever security pattern interviewers love
📖 Deep Dive Articles
Modern JavaScript: ES6+ Features Every Developer Must Know13 min read

Practice Questions

No questions tagged to this topic yet.

Tag questions in Admin → Questions by setting the "Topic Page" field to javascript-proxy-reflect-interview-questions.

Related Topics

JavaScript Object Interview Questions
Intermediate·8–12 Qs
JavaScript Generators Interview Questions
Advanced·3–5 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz