JavaScript Proxies are a game-changer for metaprogramming. They let us bend the rules of how objects work, opening up a world of possibilities for creating flexible, dynamic code.
At its core, a Proxy is like a middleman for your objects. It sits between the code that’s using an object and the object itself, letting you intercept and customize how the object behaves. This means you can add new behaviors without changing the original object at all.
Let’s start with a simple example:
const target = { name: "Alice", age: 30 };
const handler = {
get: function(obj, prop) {
console.log(`Accessing ${prop}`);
return obj[prop];
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.name); // Logs: "Accessing name" then "Alice"
Here, we’ve created a Proxy that logs every time a property is accessed. This might seem simple, but it’s just the tip of the iceberg.
One of the coolest things about Proxies is how they let you create “virtual” properties. These are properties that don’t actually exist on the object but are computed on the fly when accessed. For example:
const person = {
firstName: "John",
lastName: "Doe"
};
const personProxy = new Proxy(person, {
get: function(obj, prop) {
if (prop === "fullName") {
return `${obj.firstName} ${obj.lastName}`;
}
return obj[prop];
}
});
console.log(personProxy.fullName); // "John Doe"
In this case, fullName
doesn’t exist on the original object, but our Proxy computes it when it’s accessed. This is super useful for creating derived properties without cluttering your object with getters.
Proxies aren’t just for reading properties, though. They can also intercept and modify how properties are set. This is great for adding validation or transformation logic:
const validator = {
set: function(obj, prop, value) {
if (prop === "age") {
if (typeof value !== "number") {
throw new TypeError("Age must be a number");
}
if (value < 0 || value > 120) {
throw new RangeError("Age must be between 0 and 120");
}
}
obj[prop] = value;
return true;
}
};
const person = new Proxy({}, validator);
person.age = 30; // OK
person.age = "thirty"; // Throws TypeError
person.age = 150; // Throws RangeError
This kind of validation can make your code more robust and self-documenting. Instead of scattering checks throughout your codebase, you can centralize them in the Proxy.
One of the lesser-known but powerful features of Proxies is the ability to create revocable references. These are Proxies that can be “turned off” later, preventing any further access to the target object:
const target = { secret: "top secret info" };
const { proxy, revoke } = Proxy.revocable(target, {});
console.log(proxy.secret); // "top secret info"
revoke();
console.log(proxy.secret); // Throws TypeError
This is incredibly useful for creating temporary access to objects or for implementing security features where you need to be able to cut off access at any time.
Proxies can also be used to implement the null object pattern, which can help eliminate null checks in your code:
const nullObject = new Proxy({}, {
get: () => nullObject,
apply: () => nullObject,
construct: () => nullObject
});
const result = nullObject.nonexistent.method().property;
console.log(result === nullObject); // true
With this setup, you can chain methods and access properties on nullObject
without ever getting a “null” or “undefined” error. This can make your code more robust and easier to reason about.
One area where Proxies really shine is in creating more flexible APIs. For example, you could use a Proxy to create an object that automatically converts between different units:
const unitConverter = new Proxy({}, {
get: function(target, unit) {
return new Proxy({}, {
get: function(_, value) {
return function(to) {
const conversions = {
m: { cm: 100, km: 0.001 },
cm: { m: 0.01, km: 0.00001 },
km: { m: 1000, cm: 100000 }
};
return value * conversions[unit][to];
};
}
});
}
});
console.log(unitConverter.m[5].cm()); // 500
console.log(unitConverter.km[2].m()); // 2000
This creates an incredibly intuitive API for unit conversion, all powered by the magic of Proxies.
Proxies can also be used to implement lazy loading of properties. This can be useful for optimizing performance when dealing with large objects or expensive computations:
const lazyObject = new Proxy({}, {
get: function(target, property) {
if (!(property in target)) {
console.log(`Computing ${property}...`);
target[property] = expensiveComputation(property);
}
return target[property];
}
});
function expensiveComputation(prop) {
// Simulate an expensive operation
return prop.toUpperCase();
}
console.log(lazyObject.foo); // Logs: "Computing foo..." then "FOO"
console.log(lazyObject.foo); // Just logs: "FOO" (already computed)
This lazy loading approach can significantly improve the perceived performance of your application, especially when dealing with large datasets or complex computations.
Another interesting use case for Proxies is implementing “private” properties in JavaScript. While JavaScript now has official support for private fields, Proxies offer an alternative approach that can be useful in certain scenarios:
function createObjectWithPrivates(publicProps, privateProps) {
return new Proxy(publicProps, {
get(target, prop) {
if (prop in target) {
return target[prop];
}
if (prop in privateProps) {
throw new Error(`Cannot access private property ${prop}`);
}
},
set(target, prop, value) {
if (prop in privateProps) {
throw new Error(`Cannot modify private property ${prop}`);
}
target[prop] = value;
return true;
},
has(target, prop) {
return prop in target;
}
});
}
const obj = createObjectWithPrivates(
{ public: "I'm public" },
{ private: "I'm private" }
);
console.log(obj.public); // "I'm public"
console.log(obj.private); // Throws Error
console.log('private' in obj); // false
This approach allows you to create objects with truly private properties that can’t be accessed or modified from outside the object.
Proxies can also be used to create more robust debugging tools. For example, you could create a Proxy that logs all interactions with an object:
function createLoggingProxy(target) {
return new Proxy(target, {
get(target, property) {
console.log(`Accessed property: ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`Set property: ${property} = ${value}`);
target[property] = value;
return true;
},
deleteProperty(target, property) {
console.log(`Deleted property: ${property}`);
delete target[property];
return true;
}
});
}
const obj = createLoggingProxy({ x: 1, y: 2 });
obj.x; // Logs: "Accessed property: x"
obj.z = 3; // Logs: "Set property: z = 3"
delete obj.y; // Logs: "Deleted property: y"
This kind of logging can be invaluable when trying to track down bugs or understand how a complex system is behaving.
One of the more advanced use cases for Proxies is implementing custom object behaviors. For example, you could create an object that automatically converts all its string properties to uppercase:
const upperCaseObject = new Proxy({}, {
set(target, property, value) {
if (typeof value === 'string') {
target[property] = value.toUpperCase();
} else {
target[property] = value;
}
return true;
}
});
upperCaseObject.name = "alice";
console.log(upperCaseObject.name); // "ALICE"
upperCaseObject.age = 30;
console.log(upperCaseObject.age); // 30
This kind of behavior modification can be incredibly useful for creating objects with specialized behaviors without having to modify their prototype or create custom setter methods for each property.
Proxies can also be used to implement more complex patterns like the Observer pattern. Here’s an example of how you might use a Proxy to create an observable object:
function createObservable(target) {
const observers = new Set();
return new Proxy(target, {
set(target, property, value) {
const oldValue = target[property];
target[property] = value;
if (oldValue !== value) {
observers.forEach(observer => observer(property, value, oldValue));
}
return true;
}
});
}
const person = createObservable({ name: "Alice", age: 30 });
person.addObserver = function(observer) {
observers.add(observer);
};
person.addObserver((property, newValue, oldValue) => {
console.log(`${property} changed from ${oldValue} to ${newValue}`);
});
person.name = "Bob"; // Logs: "name changed from Alice to Bob"
person.age = 31; // Logs: "age changed from 30 to 31"
This implementation of the Observer pattern is much cleaner and more flexible than traditional approaches, thanks to the power of Proxies.
In conclusion, JavaScript Proxies are a powerful tool that opens up new possibilities for metaprogramming. They allow us to create more dynamic, flexible, and robust code structures. Whether you’re building complex libraries, designing APIs, or just looking to push your JavaScript skills to the next level, mastering Proxies will give you new ways to solve problems and create more adaptive, maintainable code. The examples we’ve explored here are just the beginning – the true power of Proxies lies in how you apply them to your specific use cases and problems. So go forth and experiment, and see what kind of magic you can work with Proxies!