Frontend

JavaScript Proxy and Reflect

Mayur Dabhi
Mayur Dabhi
May 22, 2026
14 min read

JavaScript's Proxy and Reflect APIs are among the most powerful — and underutilized — features introduced in ES2015. They open the door to true metaprogramming: the ability to intercept and redefine fundamental object operations like property access, assignment, function calls, and more. Vue 3's reactivity engine is built on Proxy. MobX, Immer, and numerous validation libraries leverage these APIs under the hood. Understanding them will fundamentally change how you think about objects in JavaScript.

Why This Matters

Vue 3 switched from Object.defineProperty() to Proxy for its reactivity system in 2020 — enabling it to detect new property additions and array index mutations that Vue 2 couldn't track. Understanding Proxy means understanding how modern reactive frameworks work at their core.

What is a Proxy?

A Proxy wraps a target object and intercepts operations performed on it through traps — handler functions that execute whenever those operations are triggered. Think of it as middleware for JavaScript objects: every get, set, delete, and function call passes through your custom handler before (or instead of) reaching the underlying object.

The basic syntax is straightforward:

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

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

const proxy = new Proxy(target, handler);

proxy.name;          // Logs: "Getting property: name" → "Alice"
proxy.age = 31;      // Logs: "Setting age = 31"
proxy.age;           // Logs: "Getting property: age" → 31

The three key participants are:

Consumer proxy.name operation Proxy handler.get() handler.set() handler.has() ... Intercepts all traps forwarded Reflect Reflect.get() Reflect.set() Reflect.has() ... Default behavior Target { name } JavaScript Proxy Interception Flow

Operations flow through the Proxy handler before reaching the target object

Essential Proxy Traps

There are 13 possible traps corresponding to JavaScript's internal object operations. Here are the ones you'll use most often:

Trap Triggered By Return Type
get(target, prop, receiver) proxy.prop, proxy['prop'] Any value
set(target, prop, value, receiver) proxy.prop = value boolean
has(target, prop) prop in proxy boolean
deleteProperty(target, prop) delete proxy.prop boolean
apply(target, thisArg, args) proxy(), proxy.call() Any value
construct(target, args) new proxy() Object
ownKeys(target) Object.keys(), for...in Array
defineProperty(target, prop, desc) Object.defineProperty() boolean

The get Trap: Read Interception

The get trap is the most commonly used. A powerful pattern is returning default values for missing properties — great for building configuration objects or safe navigation:

get trap — default values and virtual properties
// Safe property access — returns null instead of undefined
function safeObject(obj) {
    return new Proxy(obj, {
        get(target, prop) {
            return prop in target ? target[prop] : null;
        }
    });
}

const config = safeObject({ theme: "dark", language: "en" });
console.log(config.theme);      // "dark"
console.log(config.missing);    // null (not undefined)

// Virtual properties derived from existing data
const user = new Proxy({ firstName: "John", lastName: "Doe" }, {
    get(target, prop) {
        if (prop === "fullName") {
            return `${target.firstName} ${target.lastName}`;
        }
        return Reflect.get(target, prop);
    }
});

console.log(user.fullName);   // "John Doe" (property doesn't exist on target)
console.log(user.firstName);  // "John"

The set Trap: Write Validation

The set trap must return true for success or false for failure (which triggers a TypeError in strict mode). This is perfect for runtime type validation:

set trap — type validation and constraints
function createTypedObject(schema) {
    return new Proxy({}, {
        set(target, prop, value) {
            if (!(prop in schema)) {
                throw new TypeError(`Unknown property: ${prop}`);
            }
            const expectedType = schema[prop];
            if (typeof value !== expectedType) {
                throw new TypeError(
                    `Property "${prop}" must be of type ${expectedType}, got ${typeof value}`
                );
            }
            target[prop] = value;
            return true; // Required: must return true on success
        }
    });
}

const person = createTypedObject({ name: "string", age: "number" });

person.name = "Alice";   // OK
person.age = 30;         // OK
person.age = "thirty";   // TypeError: Property "age" must be of type number
person.score = 100;      // TypeError: Unknown property: score
Critical: Always Return true from set

If your set trap doesn't return true (or returns a falsy value), it will throw a TypeError: 'set' on proxy: trap returned falsish for property in strict mode. Always explicitly return true after a successful set, or use return Reflect.set(target, prop, value, receiver).

The apply Trap: Function Interception

You can proxy functions, not just objects. The apply trap fires on any function invocation:

apply trap — function middleware
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 for: ${key}`);
                return cache.get(key);
            }
            const result = Reflect.apply(target, thisArg, args);
            cache.set(key, result);
            return result;
        }
    });
}

function slowFibonacci(n) {
    if (n <= 1) return n;
    return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}

const fastFibonacci = memoize(slowFibonacci);

fastFibonacci(10);  // Computed
fastFibonacci(10);  // Cache hit! Returns immediately
fastFibonacci(20);  // Computed

The Reflect API

The Reflect object provides static methods that correspond to every Proxy trap. It's the "do the default thing" companion to Proxy. Every Reflect method mirrors a Proxy trap's signature exactly, which makes them natural pairs.

Why use Reflect instead of directly operating on the target?

Why receiver matters — prototype chain gotcha
const base = {
    get value() {
        return this._value;
    }
};

const child = Object.create(base);
child._value = 42;

// WRONG: target[prop] loses the correct receiver
const badProxy = new Proxy(child, {
    get(target, prop) {
        return target[prop]; // `this` inside getter = target, not proxy
    }
});

// CORRECT: Reflect.get passes receiver through the prototype chain
const goodProxy = new Proxy(child, {
    get(target, prop, receiver) {
        return Reflect.get(target, prop, receiver); // `this` = proxy (receiver)
    }
});

// With a prototype chain and getter, badProxy would break.
// goodProxy always works correctly.
// Reflect.get(target, propertyKey[, receiver])
// Equivalent to: target[propertyKey]

const obj = { x: 1 };
Reflect.get(obj, 'x');        // 1
Reflect.get([1, 2], 0);       // 1

// With receiver — important for getters
const parent = {
    get greeting() { return `Hello from ${this.name}`; }
};
const child = Object.create(parent);
child.name = "child";

Reflect.get(parent, 'greeting', child);  // "Hello from child"
// parent[prop] would return "Hello from undefined"
// Reflect.set(target, propertyKey, value[, receiver])
// Equivalent to: target[propertyKey] = value
// Returns: true on success, false on failure (no throws!)

const obj = {};
const success = Reflect.set(obj, 'name', 'Alice');
console.log(success);    // true
console.log(obj.name);   // "Alice"

// Sealed/frozen object
const frozen = Object.freeze({ x: 1 });
const result = Reflect.set(frozen, 'x', 2);
console.log(result);     // false (no TypeError!)
// Direct assignment in strict mode: TypeError
// frozen.x = 2; // Would throw
// Reflect.has(target, propertyKey)
// Equivalent to: prop in target
Reflect.has({ x: 1 }, 'x');        // true
Reflect.has({ x: 1 }, 'y');        // false
Reflect.has([1, 2, 3], '0');        // true

// Reflect.deleteProperty(target, propertyKey)
// Equivalent to: delete target[prop]
// Returns: true on success, false on failure
const obj = { a: 1, b: 2 };
Reflect.deleteProperty(obj, 'a');   // true
console.log(obj);                   // { b: 2 }

const locked = Object.freeze({ x: 1 });
Reflect.deleteProperty(locked, 'x'); // false (no TypeError!)
// delete locked.x; // Would throw in strict mode
// Reflect.apply(target, thisArgument, argumentsList)
// Equivalent to: Function.prototype.apply.call(target, thisArg, args)

function sum(...nums) { return nums.reduce((a, b) => a + b, 0); }
Reflect.apply(sum, null, [1, 2, 3, 4]);  // 10

// Safer than Function.prototype.apply — works even if target's
// apply method has been overridden

// Reflect.construct(target, argumentsList[, newTarget])
// Equivalent to: new target(...args)

class Point { constructor(x, y) { this.x = x; this.y = y; } }
const p = Reflect.construct(Point, [3, 4]);
console.log(p instanceof Point);  // true
console.log(p.x, p.y);           // 3, 4

Real-World Use Cases

1. Building a Reactive Data System

This is the most impactful real-world application of Proxy. Vue 3's reactivity engine is conceptually similar to the following pattern:

Reactive system — simplified Vue 3 reactivity
const subscribers = new Map(); // property → Set of callbacks
let activeEffect = null;

function reactive(target) {
    return new Proxy(target, {
        get(target, prop, receiver) {
            // Track: this property is being accessed during an effect
            if (activeEffect) {
                if (!subscribers.has(prop)) {
                    subscribers.set(prop, new Set());
                }
                subscribers.get(prop).add(activeEffect);
            }
            return Reflect.get(target, prop, receiver);
        },
        set(target, prop, value, receiver) {
            const result = Reflect.set(target, prop, value, receiver);
            // Trigger: notify all subscribers when property changes
            if (subscribers.has(prop)) {
                subscribers.get(prop).forEach(effect => effect());
            }
            return result;
        }
    });
}

function effect(fn) {
    activeEffect = fn;
    fn(); // Run immediately to collect dependencies
    activeEffect = null;
}

// Usage
const state = reactive({ count: 0, message: "Hello" });

effect(() => {
    console.log(`Count is: ${state.count}`);
});
// Logs: "Count is: 0"

state.count++;   // Logs: "Count is: 1" (automatically!)
state.count++;   // Logs: "Count is: 2"
state.message = "World";  // Nothing logged (no subscriber for message)

2. Input Validation with Schema

Schema-based validation proxy
function createValidator(target, schema) {
    return new Proxy(target, {
        set(target, prop, value) {
            const rule = schema[prop];
            if (!rule) {
                throw new Error(`Property "${prop}" is not in schema`);
            }
            if (rule.type && typeof value !== rule.type) {
                throw new TypeError(
                    `"${prop}" expects ${rule.type}, got ${typeof value}`
                );
            }
            if (rule.min !== undefined && value < rule.min) {
                throw new RangeError(
                    `"${prop}" must be >= ${rule.min}`
                );
            }
            if (rule.max !== undefined && value > rule.max) {
                throw new RangeError(
                    `"${prop}" must be <= ${rule.max}`
                );
            }
            if (rule.pattern && !rule.pattern.test(value)) {
                throw new Error(`"${prop}" doesn't match required pattern`);
            }
            return Reflect.set(target, prop, value);
        }
    });
}

const userSchema = {
    name:  { type: "string", pattern: /^[a-zA-Z\s]+$/ },
    age:   { type: "number", min: 0, max: 150 },
    email: { type: "string", pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
};

const user = createValidator({}, userSchema);

user.name = "Alice";              // OK
user.age = 25;                   // OK
user.email = "alice@example.com"; // OK
user.age = -5;                   // RangeError: "age" must be >= 0
user.name = "Alice123";          // Error: doesn't match required pattern

3. Logging and Debugging Proxy

Audit trail — log all object mutations
function withAuditLog(target, name = "Object") {
    const log = [];

    const proxy = new Proxy(target, {
        get(target, prop, receiver) {
            if (prop === "__auditLog") return log;
            return Reflect.get(target, prop, receiver);
        },
        set(target, prop, value, receiver) {
            const oldValue = Reflect.get(target, prop, receiver);
            log.push({
                type: "SET",
                property: prop,
                oldValue,
                newValue: value,
                timestamp: new Date().toISOString()
            });
            return Reflect.set(target, prop, value, receiver);
        },
        deleteProperty(target, prop) {
            const oldValue = Reflect.get(target, prop);
            log.push({
                type: "DELETE",
                property: prop,
                oldValue,
                timestamp: new Date().toISOString()
            });
            return Reflect.deleteProperty(target, prop);
        }
    });

    return proxy;
}

const cart = withAuditLog({ items: [], total: 0 }, "Cart");

cart.items = ["apple", "banana"];
cart.total = 3.50;
delete cart.total;

console.log(cart.__auditLog);
// [
//   { type: "SET", property: "items", oldValue: [], newValue: [...] },
//   { type: "SET", property: "total", oldValue: 0, newValue: 3.5 },
//   { type: "DELETE", property: "total", oldValue: 3.5 }
// ]

4. Private Properties with has Trap

Hide internal properties from external inspection
function withPrivateProps(target, prefix = "_") {
    return new Proxy(target, {
        has(target, prop) {
            // Hide properties starting with prefix from `in` checks
            if (String(prop).startsWith(prefix)) return false;
            return Reflect.has(target, prop);
        },
        ownKeys(target) {
            // Hide from Object.keys(), for...in, JSON.stringify
            return Reflect.ownKeys(target).filter(
                key => !String(key).startsWith(prefix)
            );
        },
        get(target, prop, receiver) {
            if (String(prop).startsWith(prefix)) {
                throw new Error(`Access denied: ${prop} is private`);
            }
            return Reflect.get(target, prop, receiver);
        }
    });
}

const api = withPrivateProps({
    _secretKey: "abc123xyz",
    _internalCache: {},
    publicEndpoint: "/api/data",
    version: "2.0"
});

console.log(api.publicEndpoint);   // "/api/data"
console.log("_secretKey" in api);  // false (hidden!)
console.log(Object.keys(api));     // ["publicEndpoint", "version"]
console.log(api._secretKey);       // Error: Access denied: _secretKey is private

Performance Considerations

Proxy is powerful but not free. Every intercepted operation adds overhead compared to direct property access. Here's what you need to know before reaching for Proxy in performance-sensitive paths:

1

Measure First

Proxy overhead is typically 5-10x slower than direct property access in microbenchmarks. In most real applications this is irrelevant — profile before optimizing.

2

Avoid Deep Reactive Nesting Carelessly

If you proxy an object that contains nested objects, those nested objects are NOT automatically proxied. Vue 3 wraps them lazily on access. Deep nesting with eager wrapping can be costly.

3

Proxies Are Not Transparent to Identity Checks

proxy === target is always false. proxy instanceof SomeClass may behave unexpectedly. Use WeakMap to track the relationship between proxies and their targets.

4

No Revocable Proxies in Production APIs

Proxy.revocable() creates a proxy that can be invalidated. Once revoked, any access throws a TypeError. Use this for temporary capability grants, not long-lived objects.

Proxy.revocable — temporary access grants
const { proxy, revoke } = Proxy.revocable(
    { secret: "sensitive-data" },
    {} // no-op handler
);

console.log(proxy.secret);  // "sensitive-data"

// Revoke access after use
revoke();

try {
    console.log(proxy.secret);  // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (e) {
    console.error("Access denied:", e.message);
}

// Practical use: grant temporary access to a resource
function grantTemporaryAccess(resource, durationMs) {
    const { proxy, revoke } = Proxy.revocable(resource, {});
    setTimeout(revoke, durationMs);
    return proxy;
}

const tempAccess = grantTemporaryAccess({ data: "private" }, 5000);
// tempAccess works for 5 seconds, then all access throws

Proxy vs Object.defineProperty

Before Proxy existed, developers used Object.defineProperty() for similar interception. Vue 2 was built on it. Here's why Proxy is strictly superior:

Feature Object.defineProperty Proxy
New property detection No (must pre-define) Yes
Array index mutations No (index = 0, 1... not detectable) Yes
Array.length changes No Yes
delete operator No Yes (deleteProperty trap)
in operator No Yes (has trap)
Function call interception No Yes (apply trap)
Object.keys() interception No Yes (ownKeys trap)
Browser support IE9+ Edge 14+, no IE
Polyfillable Yes No (language-level feature)
The Vue 3 Migration Story

Vue 3 completely rewrote its reactivity system using Proxy. This eliminated the need for Vue.set(obj, 'newProp', value) workarounds, made arrays fully reactive, and reduced the framework's reactivity bundle size by 40%. If you're building any form of reactive state, start with Proxy.

Putting It All Together

Let's build a mini ORM-like data model that combines validation, change tracking, and computed properties — a real-world scenario that showcases all the APIs working together:

Mini reactive model with Proxy + Reflect
class Model {
    constructor(data, schema) {
        this._data = { ...data };
        this._schema = schema;
        this._dirty = new Set();
        this._computed = {};

        return new Proxy(this, {
            get(target, prop, receiver) {
                // Expose computed properties
                if (prop in target._computed) {
                    return target._computed[prop](target._data);
                }
                // Expose raw data properties
                if (prop in target._data) {
                    return target._data[prop];
                }
                return Reflect.get(target, prop, receiver);
            },
            set(target, prop, value, receiver) {
                if (prop.startsWith("_")) {
                    return Reflect.set(target, prop, value, receiver);
                }
                const rule = target._schema[prop];
                if (rule && rule.type && typeof value !== rule.type) {
                    throw new TypeError(`${prop} must be ${rule.type}`);
                }
                target._data[prop] = value;
                target._dirty.add(prop);
                return true;
            }
        });
    }

    addComputed(name, fn) {
        this._computed[name] = fn;
        return this; // Chainable
    }

    isDirty(prop) {
        return prop ? this._dirty.has(prop) : this._dirty.size > 0;
    }

    save() {
        console.log("Saving changed fields:", [...this._dirty]);
        this._dirty.clear();
    }
}

const userModel = new Model(
    { firstName: "John", lastName: "Doe", age: 28 },
    { firstName: { type: "string" }, lastName: { type: "string" }, age: { type: "number" } }
);

userModel.addComputed("fullName", d => `${d.firstName} ${d.lastName}`);
userModel.addComputed("isAdult", d => d.age >= 18);

console.log(userModel.fullName);  // "John Doe"
console.log(userModel.isAdult);   // true

userModel.firstName = "Jane";
console.log(userModel.fullName);  // "Jane Doe"
console.log(userModel.isDirty()); // true

userModel.save(); // "Saving changed fields: ['firstName']"
console.log(userModel.isDirty()); // false

All 13 Proxy Traps Quick Reference

TrapTriggers On
getProperty reads: proxy.x, proxy['x']
setProperty writes: proxy.x = v
hasin operator: 'x' in proxy
deletePropertydelete: delete proxy.x
applyFunction calls: proxy(), proxy.call()
constructnew: new proxy()
getPrototypeOfObject.getPrototypeOf(proxy), instanceof
setPrototypeOfObject.setPrototypeOf(proxy, proto)
isExtensibleObject.isExtensible(proxy)
preventExtensionsObject.preventExtensions(proxy)
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor(proxy, prop)
definePropertyObject.defineProperty(proxy, prop, desc)
ownKeysObject.keys(), Object.getOwnPropertyNames(), for...in

Key Takeaways

What You've Learned

  • Proxy wraps objects and intercepts operations via 13 named trap functions in a handler object
  • Reflect provides the default implementations — always forward to Reflect inside your traps to avoid breaking the invariants JavaScript engines expect
  • The receiver parameter in get/set traps is critical for correct prototype chain behavior with getters/setters
  • set traps must return true on success or false on failure — never return nothing
  • Real use cases include: reactivity systems (Vue 3), input validation, audit logging, access control, memoization, and virtual properties
  • Proxy cannot be polyfilled — it's a language-level feature. Check your browser support requirements before using it
  • Proxy.revocable() creates invalidatable proxies — useful for temporary capability grants
"Metaprogramming is programming about programming — and Proxy gives JavaScript developers first-class tools to reshape how their objects behave at the language level. Use this power thoughtfully."

JavaScript Proxy and Reflect unlock a new level of expressiveness in your code. Whether you're building a reactive state system, enforcing domain invariants, or creating developer-friendly APIs, these tools let you intercept reality itself — at least, the JavaScript object reality. Start small with a logging proxy on your next debugging session and work up to the full reactive patterns as your understanding grows.

JavaScript Proxy Reflect Advanced Metaprogramming ES6
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.