Proxy — A Different Concept That Shares a Name with the Built-in Proxy
This chapter separates the built-in Proxy from Proxy the pattern and looks at where each one fits.
JavaScript has a built-in object called Proxy, and it is not the same thing as GoF's Proxy pattern. Unlike the relationship with the language feature covered in Decorator, though, these two stand in a relationship where one can implement the other.
- The GoF Proxy pattern — a design technique that puts a stand-in in place of the real object to control access or defer creation.
- The built-in
Proxy— a language feature that intercepts and redefines the operations on an object themselves.
This chapter covers both and goes as far as which one to pick.
The problem this pattern set out to solve
GoF catalogs Proxy as a pattern that provides a surrogate for another object to control access, cut cost, or hide complexity1.
From the caller's side it looks the same shape as the real thing, so swapping it in leaves the calling code unchanged. Three uses come up often.
- Deferring creation — do not build a heavy object until it is actually used
- Controlling access — turn away unauthorized calls before they land
- Logging — take note of which properties were read
The structure is nearly identical to Decorator; only the motivation differs. Decorator wraps in order to add behavior, Proxy wraps in order to do something before the call reaches the real thing.
Writing it straightforwardly with classes
The subject here is loading a large configuration file. Reading it on every startup is expensive, so it is deferred until something actually looks at it.
type SiteConfig = {siteName: string; locales: string[]};
interface ConfigStore {
read(): SiteConfig;
}
class FileConfigStore implements ConfigStore {
read(): SiteConfig {
// A file read in practice. Omitted here
return {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
}
}
// A stand-in with the same shape as the real thing. Builds the real one only on the first call
class LazyConfigStore implements ConfigStore {
private cached: SiteConfig | undefined;
constructor(private readonly createInner: () => ConfigStore) {}
read(): SiteConfig {
this.cached ??= this.createInner().read();
return this.cached;
}
}
const store: ConfigStore = new LazyConfigStore(() => new FileConfigStore());
Whatever receives a ConfigStore has no idea whether it was handed the real thing or a stand-in. This part works exactly as GoF intended.
Replacing it with language features
With a single method, this too can be written as a function. That is the same story as the previous chapters, so it is not repeated here. What is worth looking at in this chapter is what changes when you use the built-in Proxy.
The
Proxyobject enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.2
The functions that do the intercepting are called traps.
Handler functions are sometimes called traps, presumably because they trap calls to the target object.2
A stand-in written as a class had to declare every method the real thing has, one by one. Proxy automates that pass-through. Any property read at all arrives through a single get trap, so the amount you write does not change with the number of fields on the target. Here is the logging example for comparison.
type SiteConfig = {siteName: string; locales: string[]};
const config: SiteConfig = {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
// The get trap receives access to any property in one place
const observed = new Proxy(config, {
get(target, property, receiver) {
console.log(`read: ${String(property)}`);
return Reflect.get(target, property, receiver);
},
});
const shown = observed.siteName; // prints "read: siteName"
However many properties there are, the only trap you write is get. Pass-through code no longer scales with the number of fields on the target, and that is the decisive difference from a stand-in written as a class.
The typing calls for care. The return value of new Proxy(config, handler) is treated as SiteConfig, but that is only the input type coming back out. And the return type of the get trap is declared any, so whatever you return goes unchecked.
type SiteConfig = {siteName: string; locales: string[]};
const config: SiteConfig = {siteName: 'Reinvent Notes', locales: ['ja', 'en']};
// get returns a number, yet the type still passes as SiteConfig
const broken = new Proxy(config, {
get() {
return 42;
},
});
const shown: string = broken.siteName; // at runtime this holds 42
Because get returns any, type checking stops right here.
This does not apply to every trap. In TypeScript's declaration of ProxyHandler, only two return any: get and apply3. set / has / deleteProperty and others are declared boolean, construct is object, and ownKeys is ArrayLike<string | symbol>, all of which are checked. The holes are in the path that reads values and the path that calls functions.
How this series judges it
Verdict: conditional. It splits three ways.
- One method, aiming at deferred creation or caching — write a function.
LazyConfigStorecan be replaced with a function that runs once and holds its result, and there is no benefit in making it a class. - A few methods, with a tolerable amount of pass-through — write a class. Type checking works on every path, which makes it safer than
Proxy. - Many fields on the target, or a shape known only at runtime — pick the built-in
Proxy. Uses that treat every access uniformly, such as logging, monitoring, or making something read-only, fall here.
The dividing line is whether giving up type checking on reads is worth it. Proxy erases the pass-through code at the cost of type checking on the return values of get and apply. At three or four fields, writing the pass-through by hand errs on the safe side. That rule of thumb is this guide's judgment and is not based on measurement.
What would overturn this judgment
Proxy has a cost in execution speed. A trap function call is inserted on every property access, which starts to matter when you wrap an object that is read frequently. How much it matters depends on the runtime and the shape of the target, so this guide gives no numbers. In places where performance matters, measure before deciding.
One more thing: wrapping with Proxy produces a reference different from the original object. If anywhere compares identity with === or uses the object as a WeakMap key, those stop matching the moment you wrap it. When wrapping an existing object after the fact, you need to check for that.
How this relates to existing articles
- The remark in Decorator under "What would overturn this judgment" about automating pass-through with
Proxyis what this chapter fills in. That chapter's motivation is adding behavior and this one's is controlling access, but the structure is shared. - Inheritance and interfaces covers the basics of class syntax.