Symbols, Proxies and Metaprogramming
These three features let you change what the basic operations of the language mean for your objects: what happens on a property read, on a for...of, on a +. That is enormous power and it is nearly always the wrong tool. This lesson is about recognising the handful of cases where it is right - and about reading the frameworks that are built on it.
Symbols: keys that cannot collide
A symbol is a unique primitive. Two symbols with the same description are different values, so a symbol used as a key can never clash with anyone else's key.
const id = Symbol('id');
const otherId = Symbol('id');
console.log(id === otherId); // false - descriptions are labels
const user = { name: 'Aisha', [id]: 7 };
console.log(user[id]); // 7
console.log(Object.keys(user)); // ['name'] - symbols are skipped
console.log(JSON.stringify(user)); // '{"name":"Aisha"}'
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]
Open this example in the editor and change it. Nothing you type is saved.
Symbol keys are skipped by Object.keys, JSON.stringify, for...in and spread-into-JSON, but they are not private - getOwnPropertySymbols finds them. Use #private fields for privacy and symbols for metadata that should not show up in ordinary enumeration.
The well-known symbols are where it gets useful. These are the hooks the language itself calls:
class Temperature {
constructor(celsius) { this.celsius = celsius; }
// Called by for...of, spread, destructuring.
*[Symbol.iterator]() { yield this.celsius; yield this.celsius * 9 / 5 + 32; }
// Called by +, by template literals, by Number() and String().
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.celsius;
if (hint === 'string') return this.celsius + ' degrees C';
return 'Temperature(' + this.celsius + ')';
}
// Changes what Object.prototype.toString reports.
get [Symbol.toStringTag]() { return 'Temperature'; }
}
const t = new Temperature(20);
console.log([...t]); // [20, 68]
console.log(String(t)); // '20 degrees C' - string hint
console.log(Number(t)); // 20
console.log(Object.prototype.toString.call(t)); // '[object Temperature]'
// `+` passes the hint 'default', NOT 'number' - it cannot know whether
// you meant addition or concatenation, so it asks for neither.
console.log(t + 5); // 'Temperature(20)5'
console.log(Number(t) + 5); // 25
Open this example in the editor and change it. Nothing you type is saved.
That t + 5 line is the one worth remembering. Almost everyone writing Symbol.toPrimitive for the first time handles 'number' and 'string' and forgets that the operator most likely to reach it passes 'default'. If addition should mean arithmetic, return the number for the default hint; Date is the odd one out in the standard library, returning a string, which is why date1 + date2 concatenates and date1 - date2 gives milliseconds.
Symbol.iterator is the one you will implement; the rest are worth recognising when you meet them in a library.
Proxies: intercepting the operations themselves
A Proxy wraps a target object and lets you define what happens for each fundamental operation - a trap.
const target = { a: 1 };
const proxy = new Proxy(target, {
get(obj, key, receiver) {
console.log('read', String(key));
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
console.log('write', String(key), '=', value);
return Reflect.set(obj, key, value, receiver);
},
has(obj, key) { return Reflect.has(obj, key); },
deleteProperty(obj, key) { return Reflect.deleteProperty(obj, key); },
});
proxy.a; // 'read a'
proxy.b = 2; // 'write b = 2'
console.log(target.b); // 2 - the proxy wrote through to the target
Open this example in the editor and change it. Nothing you type is saved.
Thirteen traps exist in all. The ones you will use are get, set, has, deleteProperty, ownKeys and apply.
Reflect is not optional
Every trap has a matching Reflect method with the same signature, and using it is not a style preference.
const wrong = {
// Loses the receiver, so a getter on the prototype sees the wrong
// `this` - and returns nothing, which strict mode treats as failure.
set(obj, key, value) { obj[key] = value; },
};
const right = {
set(obj, key, value, receiver) { return Reflect.set(obj, key, value, receiver); },
};
Open this example in the editor and change it. Nothing you type is saved.
Two reasons. Reflect.get and Reflect.set take the receiver, which is what this should be inside an inherited getter or setter - obj[key] throws that away. And Reflect.set and Reflect.deleteProperty return a boolean; a set trap that returns a falsy value throws a TypeError in strict mode, which is every module.
The rule: write every trap as a Reflect call with the trap's own arguments, then add your behaviour around it.
What reactivity frameworks do with this
Vue's reactivity, MobX and several signal libraries are a get trap that records which effect is reading a property, and a set trap that re-runs the effects which read it. In about forty lines:
let activeEffect = null;
const dependencies = new WeakMap(); // target -> Map(key -> Set(effect))
function track(target, key) {
if (!activeEffect) return;
if (!dependencies.has(target)) dependencies.set(target, new Map());
const keys = dependencies.get(target);
if (!keys.has(key)) keys.set(key, new Set());
keys.get(key).add(activeEffect);
}
function trigger(target, key) {
const effects = dependencies.get(target)?.get(key);
if (!effects) return;
for (const effect of [...effects]) effect();
}
function reactive(object) {
return new Proxy(object, {
get(obj, key, receiver) {
track(obj, key);
const value = Reflect.get(obj, key, receiver);
// Deep reactivity, created on demand rather than up front.
return value !== null && typeof value === 'object' ? reactive(value) : value;
},
set(obj, key, value, receiver) {
const had = Reflect.get(obj, key, receiver);
const ok = Reflect.set(obj, key, value, receiver);
if (had !== value) trigger(obj, key);
return ok;
},
});
}
function effect(fn) {
const run = () => { activeEffect = run; try { fn(); } finally { activeEffect = null; } };
run();
return run;
}
// And it behaves exactly like the real thing.
const state = reactive({ count: 0, user: { name: 'Aisha' } });
const log = [];
effect(() => log.push('count is ' + state.count));
effect(() => log.push('name is ' + state.user.name));
state.count = 1; // re-runs the first effect only
state.user.name = 'Noshad'; // re-runs the second effect only
state.count = 1; // no change, no re-run
console.log(log);
// [ 'count is 0', 'name is Aisha', 'count is 1', 'name is Noshad' ]
Open this example in the editor and change it. Nothing you type is saved.
Three design decisions in there are worth noticing, because they are the same ones the real libraries made. Dependencies are keyed per property, not per object, so changing count does not re-run an effect that only read user. Nested objects are wrapped lazily inside the get trap, so a large object costs nothing until it is read. And the set trap compares before triggering, so an assignment that changes nothing costs nothing.
The invariants you cannot break
A proxy is not allowed to lie about certain things. The engine checks, and throws when a trap contradicts the target.
const frozen = Object.freeze({ a: 1 });
const liar = new Proxy(frozen, { get() { return 'different'; } });
try {
liar.a;
} catch (error) {
console.log(error.constructor.name); // 'TypeError'
}
Open this example in the editor and change it. Nothing you type is saved.
Reading a non-writable, non-configurable property must return the real value. Similarly, ownKeys must report every non-configurable key, has cannot hide one, and deleteProperty cannot claim to have removed one that is non-configurable. These invariants exist so that code holding a frozen object can trust it even if a proxy is in the way.
Four more limits worth knowing before you build on proxies:
#privatefields do not work through a proxy. A method called on the proxy has the proxy asthis, and the proxy is not the object that holds the private field. The error isCannot read private member.- Identity is not preserved.
proxy !== target, so aMapkeyed by the target will not find the proxy, and vice versa. ===andinstanceofare not trappable. There is no trap for equality.- Every operation costs. A proxied property read is substantially slower than a plain one, because it is a function call plus a
Reflectcall instead of an inline cache hit.
A worked example
// A defensive settings object: unknown keys throw instead of silently
// returning undefined, writes are validated, and deletes are refused.
// This is the one proxy use that earns its place in ordinary code.
function strict(shape, values) {
return new Proxy({ ...values }, {
get(obj, key, receiver) {
if (typeof key === 'symbol') return Reflect.get(obj, key, receiver);
if (!(key in shape)) {
throw new ReferenceError('unknown setting: ' + key);
}
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const expected = shape[key];
if (!expected) throw new ReferenceError('unknown setting: ' + key);
if (typeof value !== expected) {
throw new TypeError(key + ' must be a ' + expected + ', got ' + typeof value);
}
return Reflect.set(obj, key, value, receiver);
},
deleteProperty() { throw new TypeError('settings cannot be deleted'); },
ownKeys(obj) { return Reflect.ownKeys(obj); },
});
}
const settings = strict(
{ host: 'string', port: 'number', debug: 'boolean' },
{ host: 'localhost', port: 3306, debug: false },
);
console.log(settings.port); // 3306
settings.port = 5432;
console.log(settings.port); // 5432
console.log(Object.keys(settings)); // ['host', 'port', 'debug']
const errors = [];
const tryIt = (fn) => { try { fn(); } catch (e) { errors.push(e.constructor.name + ': ' + e.message); } };
tryIt(() => settings.prot); // a typo, caught
tryIt(() => { settings.port = '5432'; }); // a string where a number belongs
tryIt(() => { delete settings.host; }); // refused
console.log(errors);
// [ 'ReferenceError: unknown setting: prot',
// 'TypeError: port must be a number, got string',
// 'TypeError: settings cannot be deleted' ]
// The typo is the point. Without the proxy, settings.prot is undefined,
// the connection silently uses a default, and the bug surfaces in
// production as a timeout rather than here as a stack trace.
Open this example in the editor and change it. Nothing you type is saved.
The typeof key === 'symbol' guard on the get trap is not decoration. Without it, anything that probes the object - console.log, JSON.stringify, util.inspect, a for...of attempt - reads Symbol.toStringTag or Symbol.iterator and gets a thrown ReferenceError from your own trap. A strict get trap without a symbol escape hatch is unloggable, and that is a miserable afternoon.
When to use any of this
Honestly: rarely.
Symbols - yes, when you need a key that cannot collide, or when you are implementing Symbol.iterator. That second one is common and entirely ordinary.
Proxies - when the set of properties is genuinely not known in advance, and the behaviour must apply to all of them. Reactivity systems, API clients that build a URL from the property path, validation wrappers, test doubles that record every access. Four real categories.
Not proxies - for anything you could write as a method, a getter, a Map or a class. A proxy makes code harder to read, harder to debug (your stepping through a trap, not the operation), slower, and incompatible with private fields. The cost is paid by everyone who reads the file afterwards.
The test: if you can name the properties, you do not need a proxy.
When it goes wrong
| Symptom | Cause |
|---|---|
Cannot read private member through a proxy |
#fields key on the real object, not the proxy |
A set trap threw TypeError in strict mode |
The trap returned nothing; return Reflect.set(...) |
An inherited setter saw the wrong this |
obj[key] = value instead of Reflect.set with the receiver |
TypeError: proxy must report the same value |
A trap contradicted a non-configurable property |
console.log on a proxy threw |
The get trap has no escape hatch for symbol keys |
A Map lookup missed |
proxy !== target; pick one and use it consistently |
| Property access became measurably slow | Every trapped read is a double function call |
| A symbol key leaked into a log | Symbols are hidden from enumeration, not private |
A check you can run
Take the reactivity example and add console.log(state) inside one of the effects. It re-runs forever - because logging reads every property, which tracks every property, which the next write triggers.
That infinite loop in two added characters is the honest summary of metaprogramming. The power is real, the frameworks built on it are excellent, and the failure modes are of a kind that ordinary code simply does not have. Use it where the problem genuinely has no fixed set of keys, and nowhere else.