JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
Proxy traps for validation and defaults
Use Proxy set traps to reject invalid writes and get traps to supply defaults for keys that were never stored.
What you will learn
- Reject bad writes inside a set trap and return true only for writes you performed
- Decide when a set trap should return false and when it should throw a named error
- Serve defaults from a get trap without adding any keys to the target
- Guard get traps against symbol keys so coercion, await, and logging still work
Understanding Proxy traps for validation and defaults
A proxy stands in front of a target object, and each handler method replaces one internal operation that reads and writes normally perform. When you assign to a proxy, the engine calls set(target, prop, value, receiver) instead of storing anything, which is why validation cannot be skipped by ordinary assignment, and also why nothing is stored unless the trap stores it. The useful mental model is that the target holds the data, the handler holds the policy, and the proxy is the only door into the room.
The value a set trap returns is a protocol, not a courtesy: the engine coerces it to a boolean and uses it as the result of the internal [[Set]] operation. In strict mode, which includes every module and class body, a falsish result makes the assignment throw a TypeError; in sloppy mode the same assignment silently evaluates to the right-hand value while nothing changed. So return false when refusal is a normal outcome the caller will inspect with Reflect.set, and throw inside the trap when you want an error message that names the offending field.
A default produced by a get trap is computed on each read, never stored. Operations like the in operator, Object.keys, spread, and JSON key selection travel through the has, ownKeys, and getOwnPropertyDescriptor traps instead, so the object keeps reporting honestly what it actually owns while obj.count still reads 0. The price of that convenience is that get sees every lookup, including symbol keys the runtime itself uses such as Symbol.toPrimitive, Symbol.iterator, and then, so a default that answers everything hands the runtime a non-callable value and breaks string coercion or await; a typeof prop === 'symbol' check at the top of the trap avoids it.
placeholder
'use strict';
const rules = {
name: v => typeof v === 'string' && v.length > 0,
age: v => Number.isInteger(v) && v >= 0
};
const user = new Proxy({}, {
set(target, prop, value) {
if (!Object.hasOwn(rules, prop)) {
throw new TypeError(`unknown field ${String(prop)}`);
}
if (!rules[prop](value)) {
throw new TypeError(`invalid ${String(prop)}: ${String(value)}`);
}
target[prop] = value;
return true;
},
get(target, prop) {
if (typeof prop === 'symbol') return target[prop];
return prop in target ? target[prop] : '(unset)';
}
});
user.name = 'Ada';
console.log(user.name);
console.log(user.age);
try { user.age = -1; } catch (e) { console.log(e.message); }
try { user.nmae = 'Ada'; } catch (e) { console.log(e.message); }
console.log('has age:', 'age' in user);
console.log('keys:', Object.keys(user).join(','));A set trap is where writes are approved and the outcome reported as a boolean, while a get trap is where reads can answer with values the target never held.
Worked examples
return false versus throwing
Shows how a refused write surfaces as a TypeError on assignment but as a boolean through Reflect.set.
'use strict';
const nums = new Proxy({}, {
set(target, prop, value) {
if (typeof value !== 'number' || Number.isNaN(value)) return false;
target[prop] = value;
return true;
}
});
nums.total = 10;
console.log(nums.total);
try {
nums.total = 'ten';
} catch (e) {
console.log('threw:', e.constructor.name);
}
console.log(nums.total);
console.log('Reflect.set:', Reflect.set(nums, 'total', 'ten'));
console.log(nums.total);Example explained
Line 1return false refuses the write, and because the trap never touched target, no partial state is left behind.
Line 2Strict mode converts that false into a TypeError at the assignment site, so the rejection is impossible to miss.
Line 3Reflect.set performs the same internal operation but hands you the boolean instead of throwing, which suits code that wants to attempt a write.
Line 4The final read still shows 10, proving a refused write leaves the previous value intact.
Counters that start at zero
A get trap that returns 0 for unseen keys lets the ++ operator work on a completely empty object.
'use strict';
const counts = new Proxy({}, {
get(target, prop, receiver) {
if (typeof prop === 'symbol' || prop in target) {
return Reflect.get(target, prop, receiver);
}
return 0;
}
});
for (const word of ['fig', 'date', 'fig', 'fig']) {
counts[word]++;
}
console.log(counts.fig, counts.date, counts.plum);
console.log(JSON.stringify(counts));Example explained
Line 1counts[word]++ reads before it writes, so the missing key resolves to 0 and the ordinary write path then stores 1.
Line 2The symbol branch forwards internal lookups such as Symbol.toPrimitive, so the default cannot break coercion.
Line 3counts.plum reads 0 without creating a key, which is why JSON.stringify lists only two fields.
Line 4prop in target also lets inherited members through, so counts.hasOwnProperty stays a function instead of becoming 0.
Config that rejects typos
The inverse of defaults: a get trap that throws on unknown keys so a misspelled read fails immediately.
'use strict';
function fixedShape(obj) {
return new Proxy(obj, {
get(target, prop, receiver) {
if (typeof prop !== 'symbol' && !(prop in target)) {
throw new ReferenceError(`no such key: ${prop}`);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value) {
if (!(prop in target)) {
throw new TypeError(`cannot add key: ${String(prop)}`);
}
return Reflect.set(target, prop, value);
}
});
}
const config = fixedShape({ host: 'localhost', port: 8080 });
console.log(config.host, config.port);
config.port = 9090;
console.log(config.port);
try { console.log(config.prot); } catch (e) { console.log(e.message); }
try { config.hots = 'x'; } catch (e) { console.log(e.message); }Example explained
Line 1Reading config.prot throws instead of quietly returning undefined, so the typo is reported where it happens.
Line 2The set trap reuses the same prop in target test, which freezes the set of allowed keys while leaving values writable.
Line 3Reflect.set returns the boolean that [[Set]] needs, so no manual return true is required here.
Line 4Because in walks the prototype chain, config.toString still resolves normally rather than throwing.
Important notes
Proxy invariants outrank your handler: if the target has a non-writable, non-configurable own data property, a get trap that returns a different value throws a TypeError, so defaults do not mix with frozen targets.
Defaults are recomputed on every read, so returning a fresh [] or {} gives a different object each time; if callers need to mutate it, store it in the trap with target[prop] = [] and accept that a read now creates a key.
Common mistakes
Assigning to the target in the set trap and then forgetting return true: the value is stored, yet the assignment throws a TypeError in modules, so the object appears both updated and broken.
Returning the default for every key including symbols, so Symbol.toPrimitive resolves to a string or number instead of a function and any template literal, String(obj), or await on the proxy throws.
Keeping the raw target in a variable and writing through it later: the set trap never runs, and invalid data lands in the object that the proxy is supposed to be protecting.
Try it yourself
Change, predict, then run
In a browser console, wrap {} in a proxy whose set trap accepts only integers 1 to 5 for the keys q1 through q5 and throws for anything else, and whose get trap returns 'unanswered' for unset keys. Assign one valid answer and one invalid one, then print Object.keys to confirm the default was never stored.
Open the JavaScript workspaceCheck your understanding
A set trap validates the value, assigns it to the target when valid, and then ends without any return statement. What happens when valid data is assigned to the proxy inside an ES module?
- Nothing unusual: the return value of a set trap is advisory and the engine ignores it.
- The property is not stored, because a trap that returns undefined cancels the write.
- The property is stored, but the assignment throws a TypeError because the trap reported failure.
- The property is stored and the assignment expression evaluates to undefined instead of the value.
Show answer
Modules run in strict mode, and the engine coerces the trap's undefined return to false, which makes a strict-mode assignment throw a TypeError. Option 1 is tempting but wrong: the trap's own target[prop] = value already ran, so the write did happen; the boolean controls only the reported outcome, not whether the store took place. Option 4 is wrong because an assignment expression always evaluates to its right-hand value when it does not throw.