Skip to main content

5.6 Proxy and Reflect

Proxy and Reflect are metaprogramming tools that let you intercept and redefine an object's fundamental operations.


Proxy Basics​

new Proxy(target, handler) intercepts the target object's fundamental operations using traps.

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

const proxy = new Proxy(target, {
// get trap: intercept property reads
get(target, property, receiver) {
console.log(`Read: ${String(property)}`);
return Reflect.get(target, property, receiver);
},

// set trap: intercept property writes
set(target, property, value, receiver) {
console.log(`Write: ${String(property)} = ${value}`);
return Reflect.set(target, property, value, receiver);
},
});

proxy.name; // "Read: name"
proxy.age = 31; // "Write: age = 31"
console.log(proxy.age); // "Read: age" → 31

Key Traps​

get trap​

// Provide default values + prevent undefined
function withDefaults(target, defaults = {}) {
return new Proxy(target, {
get(obj, key, receiver) {
if (key in obj) return Reflect.get(obj, key, receiver);
if (key in defaults) return defaults[key];
return undefined;
},
});
}

const config = withDefaults(
{ theme: "dark" },
{ theme: "light", lang: "en", fontSize: 14, timeout: 5000 }
);

console.log(config.theme); // "dark" (directly set)
console.log(config.lang); // "en" (default)
console.log(config.missing); // undefined

// Negative index array
function negativeIndex(arr) {
return new Proxy(arr, {
get(target, prop, receiver) {
const index = Number(prop);
if (Number.isInteger(index) && index < 0) {
return Reflect.get(target, target.length + index, receiver);
}
return Reflect.get(target, prop, receiver);
},
});
}

const arr = negativeIndex([1, 2, 3, 4, 5]);
console.log(arr[-1]); // 5
console.log(arr[-2]); // 4
console.log(arr[0]); // 1
console.log(arr.length); // 5 (works normally)

set trap​

// Validation proxy
function validated(target, validators) {
return new Proxy(target, {
set(obj, prop, value, receiver) {
if (prop in validators) {
const error = validators[prop](value);
if (error) throw new TypeError(`[${prop}] ${error}`);
}
return Reflect.set(obj, prop, value, receiver);
},
});
}

const user = validated({}, {
name: v => (!v?.trim() ? "Name is required" : null),
age: v => (typeof v !== "number" || v < 0 || v > 150 ? "Age must be between 0 and 150" : null),
email: v => (!v?.includes("@") ? "Please enter a valid email" : null),
});

user.name = "Alice"; // OK
user.age = 30; // OK
user.email = "alice@example.com"; // OK

try {
user.age = -1; // TypeError: [age] Age must be between 0 and 150
} catch (e) {
console.log(e.message);
}

try {
user.email = "notanemail"; // TypeError: [email] Please enter a valid email
} catch (e) {
console.log(e.message);
}

has trap​

// Customizing the in operator
function rangeCheck(min, max) {
return new Proxy({}, {
has(target, prop) {
const num = Number(prop);
return num >= min && num <= max;
},
});
}

const range = rangeCheck(1, 100);
console.log(50 in range); // true
console.log(0 in range); // false
console.log(100 in range); // true
console.log(101 in range); // false

deleteProperty trap​

// Deletion-prevention proxy
function protected_(target, protectedKeys = []) {
return new Proxy(target, {
deleteProperty(obj, prop) {
if (protectedKeys.includes(prop)) {
throw new Error(`Cannot delete property '${prop}'`);
}
return Reflect.deleteProperty(obj, prop);
},
});
}

const obj = protected_({ id: 1, name: "Alice", temp: "deletable" }, ["id", "name"]);

delete obj.temp; // OK
try {
delete obj.id; // Error: Cannot delete property 'id'
} catch (e) {
console.log(e.message);
}

apply trap (function proxy)​

// Intercept function calls
function memoize(fn) {
const cache = new Map();

return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
},
});
}

function expensiveCalc(n) {
console.log(`Computing: ${n}`);
return n * n;
}

const fastCalc = memoize(expensiveCalc);
console.log(fastCalc(10)); // Computing: 10 → 100
console.log(fastCalc(10)); // Returns from cache immediately → 100
console.log(fastCalc(20)); // Computing: 20 → 400

// Call logging + timing
function timed(fn, label = fn.name) {
return new Proxy(fn, {
apply(target, thisArg, args) {
const start = performance.now();
const result = Reflect.apply(target, thisArg, args);
const elapsed = (performance.now() - start).toFixed(3);
console.log(`[${label}] ${elapsed}ms`);
return result;
},
});
}

construct trap (intercept new)​

// Singleton pattern with Proxy
function singleton(Class) {
let instance = null;

return new Proxy(Class, {
construct(Target, args) {
if (!instance) {
instance = Reflect.construct(Target, args);
}
return instance;
},
});
}

class Config {
constructor(env) {
this.env = env;
this.settings = {};
}
}

const SingletonConfig = singleton(Config);

const c1 = new SingletonConfig("production");
const c2 = new SingletonConfig("development");

console.log(c1 === c2); // true (same instance)
console.log(c1.env); // "production" (value from first creation)

Reflect API​

Reflect is a collection of static methods that perform fundamental object operations. It is primarily used inside Proxy traps to perform the default operation.

const obj = { x: 1, y: 2 };

// Reflect.get: read property
console.log(Reflect.get(obj, "x")); // 1
console.log(Reflect.get([1, 2, 3], 1)); // 2

// Reflect.set: write property
Reflect.set(obj, "z", 3);
console.log(obj.z); // 3

// Reflect.has: in operator
console.log(Reflect.has(obj, "x")); // true

// Reflect.deleteProperty: delete operator
console.log(Reflect.deleteProperty(obj, "z")); // true

// Reflect.ownKeys: all own keys (including Symbols)
const sym = Symbol("key");
const obj2 = { a: 1, [sym]: 2 };
console.log(Reflect.ownKeys(obj2)); // ["a", Symbol(key)]

// Reflect.construct: new call
class Point { constructor(x, y) { this.x = x; this.y = y; } }
const p = Reflect.construct(Point, [1, 2]);
console.log(p instanceof Point); // true

// Reflect.apply: function call
console.log(Reflect.apply(Math.max, null, [1, 5, 3])); // 5

// Reflect.defineProperty, getOwnPropertyDescriptor, getPrototypeOf, setPrototypeOf
// Reflect.isExtensible, preventExtensions

Reflect vs Direct Operations

// Direct operations: throw error or return false on failure (inconsistent)
// delete obj.key → returns true/false
// Object.defineProperty → throws TypeError on failure

// Reflect: always returns boolean (consistent)
const frozen = Object.freeze({ x: 1 });

console.log(Reflect.set(frozen, "x", 2)); // false (failure, no error)
console.log(Reflect.defineProperty(frozen, "y", { value: 3 })); // false

// Benefit of using Reflect in traps: delegates the default behavior correctly
const transparent = new Proxy(obj, {
get(target, prop, receiver) {
// Reflect.get: correctly forwards receiver (this)
return Reflect.get(target, prop, receiver);
},
});

Pattern 1: Read-Only Object​

function readonly(target) {
return new Proxy(target, {
set(obj, prop) {
throw new TypeError(`Read-only object: cannot modify property '${String(prop)}'`);
},
deleteProperty(obj, prop) {
throw new TypeError(`Read-only object: cannot delete property '${String(prop)}'`);
},
defineProperty() {
throw new TypeError("Read-only object: cannot define properties");
},
// Make nested objects read-only too
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "object" && value !== null) {
return readonly(value);
}
return value;
},
});
}

const config = readonly({
server: { host: "localhost", port: 3000 },
db: { host: "localhost", port: 5432 },
});

console.log(config.server.host); // "localhost"

try {
config.server.port = 8080; // TypeError!
} catch (e) {
console.log(e.message);
}

Pattern 2: Logging / Tracing​

function traced(target, name = "Object") {
return new Proxy(target, {
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "function") {
return new Proxy(value, {
apply(fn, thisArg, args) {
console.log(`[CALL] ${name}.${String(prop)}(${args.map(a => JSON.stringify(a)).join(", ")})`);
const result = Reflect.apply(fn, thisArg, args);
console.log(`[RETURN] ${JSON.stringify(result)}`);
return result;
},
});
}
console.log(`[GET] ${name}.${String(prop)} → ${JSON.stringify(value)}`);
return value;
},
set(obj, prop, value, receiver) {
console.log(`[SET] ${name}.${String(prop)} = ${JSON.stringify(value)}`);
return Reflect.set(obj, prop, value, receiver);
},
});
}

const calculator = traced({
value: 0,
add(n) { this.value += n; return this.value; },
reset() { this.value = 0; },
}, "Calculator");

calculator.add(10);
// [CALL] Calculator.add(10)
// [RETURN] 10

calculator.value = 5;
// [SET] Calculator.value = 5

Pattern 3: Vue 3 Reactive System Principle​

Vue 3 uses Proxy to implement reactive data. Here is a simplified look at the principle.

// Core idea behind Vue 3's reactive system (simplified)
class ReactiveSystem {
#effectStack = [];
#dependencies = new WeakMap(); // object → Map(property → Set(effect))

reactive(target) {
return new Proxy(target, {
get: (obj, prop, receiver) => {
this.#track(obj, prop); // track dependency
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "object" && value !== null) {
return this.reactive(value); // make nested objects reactive too
}
return value;
},
set: (obj, prop, value, receiver) => {
const result = Reflect.set(obj, prop, value, receiver);
this.#trigger(obj, prop); // notify change
return result;
},
});
}

effect(fn) {
const runEffect = () => {
this.#effectStack.push(runEffect);
fn();
this.#effectStack.pop();
};
runEffect();
return runEffect;
}

#track(target, prop) {
if (this.#effectStack.length === 0) return;
if (!this.#dependencies.has(target)) {
this.#dependencies.set(target, new Map());
}
const depsMap = this.#dependencies.get(target);
if (!depsMap.has(prop)) depsMap.set(prop, new Set());
depsMap.get(prop).add(this.#effectStack[this.#effectStack.length - 1]);
}

#trigger(target, prop) {
const depsMap = this.#dependencies.get(target);
if (!depsMap) return;
depsMap.get(prop)?.forEach(effect => effect());
}
}

// Usage example
const rs = new ReactiveSystem();

const state = rs.reactive({ count: 0, name: "App" });

// effect: re-runs automatically when dependencies change
rs.effect(() => {
console.log(`Count: ${state.count}, Name: ${state.name}`);
});
// Runs immediately: "Count: 0, Name: App"

state.count++; // "Count: 1, Name: App"
state.count++; // "Count: 2, Name: App"
state.name = "Updated App"; // "Count: 2, Name: Updated App"

Practical Example: Schema-Based Data Model​

function createModel(schema) {
return class Model {
constructor(data = {}) {
const target = {};

// Schema-based initialization
for (const [key, rules] of Object.entries(schema)) {
target[key] = key in data ? data[key] : rules.default;
}

return new Proxy(this, {
get(obj, prop, receiver) {
if (prop in target) return target[prop];
return Reflect.get(obj, prop, receiver);
},
set(obj, prop, value, receiver) {
if (prop in schema) {
const rules = schema[prop];

// Type check
if (rules.type && typeof value !== rules.type) {
throw new TypeError(`${prop}: expected type ${rules.type}, got ${typeof value}`);
}

// Custom validation
if (rules.validate) {
const error = rules.validate(value);
if (error) throw new RangeError(`${prop}: ${error}`);
}

// Transform
if (rules.transform) value = rules.transform(value);

target[prop] = value;
return true;
}
return Reflect.set(obj, prop, value, receiver);
},
has(obj, prop) {
return prop in schema || Reflect.has(obj, prop);
},
ownKeys() {
return [...Object.keys(schema), ...Reflect.ownKeys(obj)];
},
});
}

toJSON() {
const result = {};
for (const key of Object.keys(schema)) {
result[key] = this[key];
}
return result;
}
};
}

const UserModel = createModel({
name: {
type: "string",
default: "",
validate: v => !v.trim() ? "Name is required" : null,
transform: v => v.trim(),
},
age: {
type: "number",
default: 0,
validate: v => v < 0 || v > 150 ? "Please enter a valid age" : null,
},
email: {
type: "string",
default: "",
validate: v => !v.includes("@") ? "Please enter a valid email" : null,
transform: v => v.toLowerCase(),
},
});

const user = new UserModel({ name: " Alice ", age: 30, email: "ALICE@EXAMPLE.COM" });
console.log(user.name); // "Alice" (transform applied)
console.log(user.email); // "alice@example.com" (lowercased)
console.log(JSON.stringify(user.toJSON()));
// {"name":"Alice","age":30,"email":"alice@example.com"}

try {
user.age = -1; // RangeError: age: Please enter a valid age
} catch (e) {
console.log(e.message);
}

Pro Tips​

Tip 1: Revocable Proxy — Revocable Access

const { proxy: secureData, revoke } = Proxy.revocable(
{ secret: "secret data", token: "abc123" },
{
get(obj, prop) {
console.log(`Secure access: ${prop}`);
return obj[prop];
},
}
);

console.log(secureData.token); // "Secure access: token" → "abc123"

// Revoke access
revoke();

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

Tip 2: Type-Safe API Client

function createApiClient(baseURL) {
const handler = {
get(target, endpoint) {
return new Proxy({}, {
get(_, method) {
return (data) => {
const url = `${baseURL}/${endpoint}`;
console.log(`${method.toUpperCase()} ${url}`, data ?? "");
// In practice: fetch(url, { method, body: JSON.stringify(data) })
return Promise.resolve({ endpoint, method, data });
};
},
});
},
};

return new Proxy({}, handler);
}

const api = createApiClient("https://api.example.com");

// api.users.get() → GET https://api.example.com/users
// api.posts.post({ title: "Hello" }) → POST https://api.example.com/posts

api.users.get();
api.users.post({ name: "Alice", email: "alice@example.com" });
api.posts.get();
api.posts.delete(1);

Tip 3: Performance Considerations

// Proxy adds overhead to every operation
// Use with care in hot paths (frequently called code paths)

// ❌ Proxy usage inside high-performance loops
// for (let i = 0; i < 1_000_000; i++) {
// proxy.value++; // trap called on every operation
// }

// ✅ Use Proxy only when needed
// const raw = proxy; // can't get the original reference from a Proxy
// — design with the original and proxy kept separate from the start