Skip to main content
Advertisement

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
Advertisement