Proxies and Reflect

โ–ถ Try It Yourself

The Proxy object is one of the most powerful metaprogramming features in JavaScript. A Proxy wraps another object (the target) and intercepts fundamental operations on it โ€” property reads, writes, deletions, function calls, in checks, and more. Every intercepted operation is handled by a trap function you define. The Reflect API is the companion to Proxy โ€” it provides the same operations as Proxy traps as standalone functions, making it easy to implement the default behaviour inside a trap. Together, Proxy and Reflect enable validation, logging, computed properties, deep reactivity, and operator overloading patterns.

Proxy Traps Reference

Trap Intercepts Default via Reflect
get(target, prop, receiver) obj.prop / obj[prop] Reflect.get(target, prop, receiver)
set(target, prop, value, receiver) obj.prop = value Reflect.set(target, prop, value, receiver)
has(target, prop) 'prop' in obj Reflect.has(target, prop)
deleteProperty(target, prop) delete obj.prop Reflect.deleteProperty(target, prop)
apply(target, thisArg, args) Function call fn() Reflect.apply(target, thisArg, args)
construct(target, args, newTarget) new Fn() Reflect.construct(target, args, newTarget)
ownKeys(target) Object.keys(), for...in Reflect.ownKeys(target)
getPrototypeOf(target) Object.getPrototypeOf() Reflect.getPrototypeOf(target)
defineProperty(target, prop, desc) Object.defineProperty() Reflect.defineProperty(target, prop, desc)

Reflect Methods

Reflect Method Equivalent To Advantage
Reflect.get(obj, key) obj[key] Returns undefined instead of throwing
Reflect.set(obj, key, val) obj[key] = val Returns boolean success โ€” doesn’t throw
Reflect.has(obj, key) key in obj Functional form โ€” usable as callback
Reflect.ownKeys(obj) Object.getOwnPropertyNames + Symbols All own keys including Symbols
Reflect.apply(fn, this, args) fn.apply(this, args) Always works โ€” even if apply is overridden
Reflect.construct(Fn, args) new Fn(...args) Dynamic construction without spread
Reflect.deleteProperty(obj, key) delete obj[key] Returns boolean โ€” doesn’t throw in strict mode
Note: The set trap must return true to indicate success, or false to indicate failure. In strict mode, returning false from a set trap causes a TypeError to be thrown at the call site. Always return Reflect.set(target, prop, value, receiver) as the default โ€” this correctly propagates the success/failure signal and handles prototype chain interactions via receiver.
Tip: Use Reflect inside Proxy traps to implement the default behaviour while adding your custom logic around it. Reflect.get(target, prop, receiver) correctly handles prototype lookups and getter functions. Using target[prop] directly inside a get trap bypasses this and can cause infinite recursion or incorrect this binding.
Warning: Proxies have a performance cost โ€” every intercepted operation runs through a JavaScript function instead of native code. Avoid wrapping hot paths (tight loops, frequently accessed properties) in Proxies. They are best suited for configuration objects, validation layers, reactive state (created once, accessed occasionally), and developer tooling.

Basic Example

// โ”€โ”€ Validation proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function createValidated(target, validators) {
    return new Proxy(target, {
        set(target, prop, value, receiver) {
            const validate = validators[prop];
            if (validate) {
                const error = validate(value);
                if (error) throw new TypeError(`${prop}: ${error}`);
            }
            return Reflect.set(target, prop, value, receiver);
        },
        get(target, prop, receiver) {
            return Reflect.get(target, prop, receiver);
        }
    });
}

const user = createValidated({}, {
    name:  v => typeof v !== 'string'             ? 'must be a string' : null,
    age:   v => (typeof v !== 'number' || v < 0)  ? 'must be a non-negative number' : null,
    email: v => !v.includes('@')                  ? 'must be a valid email' : null,
});

user.name  = 'Alice';   // OK
user.age   = 30;         // OK
try {
    user.age = -5;       // TypeError: age: must be a non-negative number
} catch (e) { console.error(e.message); }

// โ”€โ”€ Logging / tracing proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function createTraced(target, label = 'proxy') {
    return new Proxy(target, {
        get(target, prop, receiver) {
            const value = Reflect.get(target, prop, receiver);
            if (typeof value === 'function') {
                return function (...args) {
                    console.log(`[${label}] ${String(prop)}(${args.map(JSON.stringify).join(', ')})`);
                    const result = value.apply(this === proxy ? target : this, args);
                    console.log(`[${label}] ${String(prop)} ->`, result);
                    return result;
                };
            }
            console.log(`[${label}] get ${String(prop)} =`, value);
            return value;
        },
        set(target, prop, value, receiver) {
            console.log(`[${label}] set ${String(prop)} =`, value);
            return Reflect.set(target, prop, value, receiver);
        }
    });
}

const arr = createTraced([], 'arr');
arr.push(1, 2, 3);    // [arr] push(1, 2, 3) -> 3
console.log(arr[0]);  // [arr] get 0 = 1

// โ”€โ”€ Default values proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function withDefaults(target, defaults) {
    return new Proxy(target, {
        get(target, prop, receiver) {
            const val = Reflect.get(target, prop, receiver);
            return val !== undefined ? val : defaults[prop];
        }
    });
}

const config = withDefaults(
    { theme: 'dark' },
    { theme: 'light', lang: 'en', fontSize: 16, sidebar: true }
);

console.log(config.theme);    // 'dark' โ€” override
console.log(config.lang);     // 'en' โ€” default
console.log(config.fontSize); // 16 โ€” default

// โ”€โ”€ Revocable proxy โ€” temporary access โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const { proxy, revoke } = Proxy.revocable({ secret: 42 }, {
    get(target, prop) {
        console.log(`Accessing ${String(prop)}`);
        return Reflect.get(target, prop);
    }
});

console.log(proxy.secret);   // Accessing secret โ†’ 42
revoke();                     // Cut off access
try {
    console.log(proxy.secret); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch(e) { console.error(e.message); }

// โ”€โ”€ Deep reactive proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function reactive(obj, onChange) {
    return new Proxy(obj, {
        get(target, prop, receiver) {
            const val = Reflect.get(target, prop, receiver);
            // Recursively proxy nested objects
            return val !== null && typeof val === 'object'
                ? reactive(val, onChange)
                : val;
        },
        set(target, prop, value, receiver) {
            const result = Reflect.set(target, prop, value, receiver);
            if (result) onChange(prop, value);
            return result;
        }
    });
}

const state = reactive({ user: { name: 'Alice', age: 30 } }, (prop, val) => {
    console.log(`State changed: ${prop} =`, val);
});

state.user.name = 'Bob';   // State changed: name = Bob
state.user.age  = 31;      // State changed: age = 31

How It Works

Step 1 โ€” Proxy Wraps a Target with a Handler

new Proxy(target, handler) creates a proxy object. All operations on the proxy are forwarded to the handler object. If a handler method (trap) exists, it intercepts the operation. If not, the operation passes through to the target unchanged. The target and handler are separate objects โ€” you can use the same handler for multiple targets.

Step 2 โ€” Reflect Mirrors Every Proxy Trap

For every trap a Proxy can define, there is a corresponding Reflect method that performs the default operation. Reflect.get(target, prop, receiver) does exactly what a get trap would do without interception โ€” it reads the property from the target, walking the prototype chain and invoking getters with the correct this. Using Reflect inside traps is the correct way to implement “intercept and forward.”

Step 3 โ€” The receiver Parameter Handles Getters Correctly

When a property access goes through a getter, this inside the getter should be the proxy (or derived object), not the raw target. The receiver parameter captures this. Always pass receiver to Reflect.get() and Reflect.set() โ€” skipping it breaks inheritance and getters on proxied objects.

Step 4 โ€” Revocable Proxies Enable Capability Revocation

Proxy.revocable(target, handler) returns { proxy, revoke }. After revoke() is called, any operation on the proxy throws a TypeError. This enables security patterns where you grant temporary, revocable access to an object โ€” useful for sandboxing, session tokens, and capability-based access control.

Step 5 โ€” Deep Reactivity Requires Recursive Proxying

A simple proxy on a root object intercepts top-level property access but not nested object mutations. To make nested objects reactive, wrap the returned value of a get trap in another proxy if it is an object. This is the core technique used by Vue 3’s reactivity system โ€” the entire state tree is lazily wrapped as proxies are accessed.

Real-World Example: Schema Validator Proxy

// schema-validator.js

function createSchema(schema) {
    const types = {
        string:   v => typeof v === 'string',
        number:   v => typeof v === 'number' && !isNaN(v),
        boolean:  v => typeof v === 'boolean',
        array:    v => Array.isArray(v),
        object:   v => v !== null && typeof v === 'object' && !Array.isArray(v),
    };

    return function validate(obj) {
        return new Proxy(obj, {
            set(target, prop, value, receiver) {
                const rule = schema[prop];

                if (rule) {
                    if (rule.required && (value === null || value === undefined)) {
                        throw new TypeError(`${String(prop)}: field is required`);
                    }
                    if (rule.type && !types[rule.type]?.(value)) {
                        throw new TypeError(`${String(prop)}: expected ${rule.type}, got ${typeof value}`);
                    }
                    if (rule.min !== undefined && value < rule.min) {
                        throw new RangeError(`${String(prop)}: must be >= ${rule.min}`);
                    }
                    if (rule.max !== undefined && value > rule.max) {
                        throw new RangeError(`${String(prop)}: must be <= ${rule.max}`);
                    }
                    if (rule.pattern && !rule.pattern.test(value)) {
                        throw new TypeError(`${String(prop)}: does not match required pattern`);
                    }
                    if (rule.enum && !rule.enum.includes(value)) {
                        throw new TypeError(`${String(prop)}: must be one of [${rule.enum.join(', ')}]`);
                    }
                }

                return Reflect.set(target, prop, value, receiver);
            },

            get(target, prop, receiver) {
                const rule = schema[prop];
                const val  = Reflect.get(target, prop, receiver);
                if (val === undefined && rule?.default !== undefined) {
                    return rule.default;
                }
                return val;
            }
        });
    };
}

const validateUser = createSchema({
    name:  { type: 'string', required: true, min: 2 },
    age:   { type: 'number', min: 0, max: 150 },
    email: { type: 'string', pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
    role:  { type: 'string', enum: ['user', 'admin', 'moderator'], default: 'user' },
});

const user = validateUser({});
user.name  = 'Alice';
user.age   = 30;
user.email = 'alice@example.com';
console.log(user.role);    // 'user' โ€” default value

try { user.age = 200; }    // RangeError: age: must be <= 150
catch(e) { console.error(e.message); }

try { user.role = 'superuser'; }  // TypeError: role: must be one of [user, admin, moderator]
catch(e) { console.error(e.message); }

Common Mistakes

Mistake 1 โ€” Not using Reflect in the set trap โ€” not returning true

โŒ Wrong โ€” set trap returns undefined (falsy) in strict mode, causing TypeError:

const p = new Proxy({}, {
    set(target, prop, value) {
        target[prop] = value;
        // Missing return โ€” returns undefined โ€” TypeError in strict mode!
    }
});

โœ… Correct โ€” return Reflect.set:

const p = new Proxy({}, {
    set(target, prop, value, receiver) {
        target[prop] = value;
        return Reflect.set(target, prop, value, receiver);  // returns true
    }
});

Mistake 2 โ€” Mutating the target directly inside a get trap โ€” missing receiver

โŒ Wrong โ€” breaks inherited getters:

get(target, prop) {
    return target[prop];  // skips prototype chain correctly but wrong this for getters
}

โœ… Correct โ€” always use Reflect.get with receiver:

get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
}

Mistake 3 โ€” Proxying hot paths causing performance degradation

โŒ Wrong โ€” Proxy on a function called thousands of times per second:

const fn = new Proxy(heavyComputeFn, {
    apply(target, thisArg, args) {
        return Reflect.apply(target, thisArg, args);
    }
});
// Each of 100,000 calls has extra proxy overhead

โœ… Correct โ€” use Proxy only for configuration / reactive state, not hot paths:

// Proxy the config object read rarely โ€” not the compute function called constantly
const config = new Proxy(rawConfig, validationHandler);

▶ Try It Yourself

Quick Reference

Task Code
Create proxy new Proxy(target, { get(t,p,r){}, set(t,p,v,r){} })
Default get return Reflect.get(target, prop, receiver)
Default set return Reflect.set(target, prop, value, receiver)
Intercept in has(target, prop) { return Reflect.has(target, prop) }
Intercept call apply(target, thisArg, args) { return Reflect.apply(...) }
Revocable proxy const { proxy, revoke } = Proxy.revocable(target, handler)
All own keys Reflect.ownKeys(obj)

🧠 Test Yourself

Why should you use Reflect.set(target, prop, value, receiver) instead of target[prop] = value inside a Proxy set trap?





โ–ถ Try It Yourself