Normally, reading a property returns a stored value and writing one stores a value. Sometimes you want code to run instead: to compute a value, to validate input, or to react to changes. Getters and setters do this for named properties you know in advance. A Proxy does it for every operation on an object, including properties that do not exist yet.
Getters and setters
A getter is a method that runs when a property is read. A setter runs when it is assigned. From the outside they look like ordinary properties.
const course = {
title: 'JavaScript',
lessons: 54,
completed: 27,
get progress() {
return Math.round((this.completed / this.lessons) * 100);
},
};
console.log(course.progress); // → 50
course.completed = 54;
console.log(course.progress); // → 100
progress is always up to date because it is computed on each read. There is no stored value to fall out of sync.
In a class, a setter pairs naturally with a private field for validation:
class Temperature {
#celsius = 0;
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeError(`celsius must be a number, received ${String(value)}`);
}
this.#celsius = value;
}
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
}
const temp = new Temperature();
temp.celsius = 20;
console.log(temp.fahrenheit); // → 68
try {
temp.celsius = 'warm';
} catch (error) {
console.log(error.message); // → celsius must be a number, received warm
}
fahrenheit has a getter but no setter, so it is read-only. Assigning to it throws a TypeError in strict mode, which applies inside classes and ES modules; in sloppy-mode scripts the assignment is silently ignored.
You can also add accessors to an existing object with Object.defineProperty:
const user = { first: 'Ada', last: 'Lovelace' };
Object.defineProperty(user, 'fullName', {
get() {
return `${this.first} ${this.last}`;
},
enumerable: false,
});
console.log(user.fullName); // → Ada Lovelace
console.log(Object.keys(user)); // → [ 'first', 'last' ]
Keep getters cheap and free of side effects. Code that reads obj.total does not expect a network request or a changed object. If a value is expensive to compute, use a method such as calculateTotal() so the cost is visible at the call site.
Proxy: intercepting operations
new Proxy(target, handler) creates an object that stands in front of target. Operations on the proxy are passed to traps on the handler if they exist, and go straight to the target if they do not.
const target = { title: 'JavaScript' };
const logged = new Proxy(target, {
get(obj, key, receiver) {
console.log(`read ${String(key)}`);
return Reflect.get(obj, key, receiver);
},
});
console.log(logged.title);
// → read title
// → JavaScript
The common traps:
| Trap | Intercepts |
|---|---|
get | reading proxy.key or proxy[key] |
set | assigning proxy.key = value |
has | the in operator |
deleteProperty | delete proxy.key |
ownKeys | Object.keys, for...in, Reflect.ownKeys and similar |
apply | calling the proxy, when the target is a function |
construct | new proxy(), when the target is a constructor |
String(key) is used in the log because keys can be symbols, and a symbol cannot be converted to a string with a template literal directly.
Reflect: the default behaviour
Inside a trap, you usually want to do something extra and then carry on as normal. The Reflect object has one method per trap, with the same arguments, that performs the default operation:
| Trap | Default behaviour |
|---|---|
get(obj, key, receiver) | Reflect.get(obj, key, receiver) |
set(obj, key, value, receiver) | Reflect.set(obj, key, value, receiver) |
has(obj, key) | Reflect.has(obj, key) |
deleteProperty(obj, key) | Reflect.deleteProperty(obj, key) |
Passing receiver through matters when the target has getters or setters: it makes this inside them refer to the proxy, so their own property reads also go through your traps.
Validating writes
A validation proxy checks every assignment, including properties added later:
function createCourse(data) {
const rules = {
title: (v) => typeof v === 'string' && v.trim() !== '',
lessons: (v) => Number.isInteger(v) && v > 0,
level: (v) => ['beginner', 'intermediate', 'advanced'].includes(v),
};
return new Proxy(data, {
set(obj, key, value, receiver) {
if (!(key in rules)) {
throw new TypeError(`Unknown course property "${String(key)}"`);
}
if (!rules[key](value)) {
throw new TypeError(`Invalid value for ${String(key)}: ${JSON.stringify(value)}`);
}
return Reflect.set(obj, key, value, receiver);
},
deleteProperty(obj, key) {
throw new TypeError(`Cannot delete course property "${String(key)}"`);
},
});
}
const course = createCourse({ title: 'CSS', lessons: 34, level: 'beginner' });
course.lessons = 35; // Fine.
console.log(course.lessons); // → 35
try {
course.lessons = -1;
} catch (error) {
console.log(error.message); // → Invalid value for lessons: -1
}
try {
course.lesons = 40; // Typo.
} catch (error) {
console.log(error.message); // → Unknown course property "lesons"
}
A set trap must return true when the write succeeded. If it returns false, strict-mode code throws a generic TypeError. Throwing your own error with a clear message, as above, is friendlier. Note that the proxy only checks writes made after it was created; the initial data is not validated, so validate it separately if it can be wrong.
Reacting to changes
The same idea underlies reactive state in many UI libraries: a set trap notices a change and tells interested code.
function observable(target, onChange) {
return new Proxy(target, {
set(obj, key, value, receiver) {
const old = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (ok && old !== value) {
onChange(key, value, old);
}
return ok;
},
});
}
const filters = observable({ topic: 'all', search: '' }, (key, value) => {
console.log(`${key} changed to "${value}"`);
});
filters.search = 'grid'; // → search changed to "grid"
filters.search = 'grid'; // Same value: nothing logged.
filters.topic = 'CSS'; // → topic changed to "CSS"
On the course dashboard, onChange could re-render the list whenever a filter changes. This proxy is shallow: changing filters.nested.value would not be noticed, because reading filters.nested returns the plain inner object.
Where proxies break
Proxies are powerful, but they have sharp edges:
- Identity. A proxy is a different object from its target:
proxy === targetisfalse. Code that stores the target in aSetwill not find the proxy in it. - Private fields and internal slots. Methods that use private fields, or built-ins with internal state such as
Map,SetandDate, fail whenthisis the proxy:
class Counter {
#count = 0;
increment() {
this.#count++;
return this.#count;
}
}
const proxied = new Proxy(new Counter(), {});
try {
proxied.increment();
} catch (error) {
console.log(error instanceof TypeError); // → true
}
const map = new Proxy(new Map(), {});
try {
map.set('a', 1);
} catch (error) {
console.log(error instanceof TypeError); // → true
}
this inside increment is the proxy, and the proxy has no #count. The usual fix is a get trap that binds methods to the target, which in turn bypasses your traps for anything those methods do.
- Invariants. The engine enforces some rules, for example a
gettrap cannot report a different value for a non-writable, non-configurable property. Breaking one throws aTypeError. - Performance. Every intercepted operation runs a function. Proxies are fine for configuration, validation at boundaries and development tools, but avoid them in hot loops.
- Debugging. Behaviour that hides behind ordinary-looking property access surprises readers. Use a proxy when the interception is the point, and document it.
Proxy.revocable(target, handler) returns { proxy, revoke }. After revoke(), any operation on the proxy throws, which is a way to hand out temporary access to an object.
Summary
- Getters and setters run code when a known property is read or written; use them for computed values and validation.
- A
Proxyintercepts operations such asget,set,hasanddeletePropertyon any property, including new ones. - Use
Reflectmethods inside traps to perform the default behaviour, passingreceiverthrough. - A
settrap must returntrueon success; throw a clear error yourself when rejecting a value. - Proxies break private fields and built-ins with internal state, change identity and cost performance.