JavaScript Advanced #6 Symbol and Proxy
Following #5 Memory Model, this post covers the more exotic JavaScript tools. Two features rarely seen in everyday code but found inside libraries all the time — Symbol and Proxy.
Symbol — unique, collision-free keys #
JavaScript’s 7th primitive type (Basics #2).
const id1 = Symbol('id');
const id2 = Symbol('id');
console.log(id1 === id2); // false — same description, different symbols
console.log(typeof id1); // 'symbol'
console.log(id1.toString()); // 'Symbol(id)'
Created with Symbol(description). The argument is just a debug description, not identity. Each call produces a brand-new, unique value.
As object keys — without collisions #
When two libraries both need to attach metadata to an object and you don’t want their keys to collide:
const userInfo = Symbol('userInfo');
const obj = {};
obj[userInfo] = { id: 'u1' }; // doesn't clash with regular keys
obj.userInfo = 'string key'; // these two keys are entirely separate
obj[userInfo]; // { id: 'u1' }
obj.userInfo; // 'string key'
Even with the same name, a Symbol key and a string key are distinct slots. Libraries use this to safely attach their own metadata.
Symbol keys are invisible to regular iteration #
const sym = Symbol('hidden');
const obj = {
visible: 'A',
[sym]: 'B',
};
Object.keys(obj); // ['visible']
JSON.stringify(obj); // '{"visible":"A"}'
for (const k in obj) console.log(k); // visible
Object.getOwnPropertySymbols(obj); // [Symbol(hidden)]
Reflect.ownKeys(obj); // ['visible', Symbol(hidden)]
for...in, Object.keys, and JSON all ignore Symbol keys. To see them, use Object.getOwnPropertySymbols or Reflect.ownKeys. This makes Symbols a natural fit for hidden metadata.
Well-known Symbols — keys the language itself uses #
The JavaScript standard defines special Symbols. Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, etc.
Symbol.iterator, seen in Intermediate #3 Iterators and Generators, is the most famous.
const range = {
from: 1, to: 3,
[Symbol.iterator]() {
let n = this.from;
const last = this.to;
return {
next() {
return n <= last
? { value: n++, done: false }
: { value: undefined, done: true };
},
};
},
};
[...range]; // [1, 2, 3]
Why a Symbol here? A regular string key could collide with a user-defined key of the same name. Language-level “contract keys” are defined as Symbols to eliminate any risk of collision.
Symbol.toPrimitive — customize primitive conversion #
You can decide how an object converts when used in numeric/string positions.
const money = {
amount: 1000,
currency: 'KRW',
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
if (hint === 'string') return `${this.amount}${this.currency}`;
return `${this.amount}${this.currency}`;
},
};
+money; // 1000 (numeric position — 'number' hint)
`${money}`; // '1000KRW' (string position — 'string' hint)
money + 500; // '1000KRW500' (default hint — usually string)
You’ll rarely need this in application code, but libraries that must precisely control type-conversion behavior sometimes use it.
Symbol.for — global symbol registry #
When you want the same key to be shared across modules.
const a = Symbol.for('app/secret');
const b = Symbol.for('app/secret');
a === b; // true — same registered key, same symbol
Symbol.keyFor(a); // 'app/secret'
Symbols registered in a global registry. Plain Symbol(...) produces a new unique value every time, but Symbol.for(...) returns the same Symbol for the same key. Used when libraries need to share metadata across module boundaries.
Proxy — intercept object operations #
Proxy is a metaprogramming tool that lets you intercept every operation (read, write, delete, etc.) on an object.
const target = { a: 1, b: 2 };
const proxy = new Proxy(target, {
get(obj, key) {
console.log(`read: ${key}`);
return obj[key];
},
set(obj, key, value) {
console.log(`write: ${key} = ${value}`);
obj[key] = value;
return true; // success indicator
},
});
proxy.a; // read: a → 1
proxy.b = 10; // write: b = 10
With new Proxy(target, handler), you define which operations to intercept on the handler object.
Operations you can intercept (traps) #
| trap | Triggered by |
|---|---|
get | reading a property (obj.a) |
set | writing a property (obj.a = 1) |
has | 'a' in obj |
deleteProperty | delete obj.a |
ownKeys | Object.keys, Reflect.ownKeys |
apply | function call (fn()) |
construct | new fn() |
There are about 13 traps in total. You can intercept nearly every object operation.
Proxy in practice #
1) Default-value object #
const withDefault = (defaultValue) => new Proxy({}, {
get(target, key) {
return key in target ? target[key] : defaultValue;
},
});
const counts = withDefault(0);
counts.apple = 3;
console.log(counts.apple); // 3
console.log(counts.banana); // 0 (missing but default)
The get trap checks key presence and returns a default.
2) Validation — block bad values #
function createValidatedUser() {
return new Proxy({}, {
set(target, key, value) {
if (key === 'age' && (typeof value !== 'number' || value < 0)) {
throw new TypeError('age must be a positive number');
}
target[key] = value;
return true;
},
});
}
const user = createValidatedUser();
user.name = 'Curtis'; // OK
user.age = 30; // OK
user.age = -1; // ✗ TypeError
user.age = 'thirty'; // ✗ TypeError
Throw an error or return false from the set trap to reject the assignment.
3) Reactive — track changes #
The core mechanism that libraries like Vue and MobX use to track object changes.
function reactive(target, onChange) {
return new Proxy(target, {
get(obj, key) {
const value = obj[key];
if (typeof value === 'object' && value !== null) {
return reactive(value, onChange); // nested reactive too
}
return value;
},
set(obj, key, value) {
const old = obj[key];
obj[key] = value;
onChange({ key, old, value });
return true;
},
});
}
const state = reactive({ count: 0 }, (e) => {
console.log('changed:', e);
});
state.count++;
// changed: { key: 'count', old: 0, value: 1 }
This skeleton is at the heart of Vue’s ref/reactive. Add dependency tracking and subscriber notification on intercepted operations, and you have a real reactive system.
4) Auto API calls — namespace interception #
const api = new Proxy({}, {
get(target, endpoint) {
return (params) => {
return fetch(`/api/${endpoint}?${new URLSearchParams(params)}`)
.then((r) => r.json());
};
},
});
const users = await api.users({ active: true });
const posts = await api.posts({ tag: 'js' });
// every undefined property becomes a fetch function
Patterns like this show up in some SDK libraries. Concise and clever — but autocomplete and TypeScript inference suffer, so use with care.
Reflect — Proxy’s partner
#
Reflect is a built-in object that exposes the default object operations as functions. Inside Proxy traps, it is used to fall through to the original behavior.
const target = {};
const proxy = new Proxy(target, {
get(obj, key, receiver) {
console.log(`read: ${key}`);
return Reflect.get(obj, key, receiver); // original behavior
},
set(obj, key, value, receiver) {
console.log(`write: ${key}`);
return Reflect.set(obj, key, value, receiver);
},
});Inside a trap, Reflect.get(...) is more accurate than obj[key] directly. It handles getters/setters and the receiver correctly.
Common Reflect methods:
Reflect.get(obj, key); // obj[key]
Reflect.set(obj, key, value); // obj[key] = value
Reflect.has(obj, key); // key in obj
Reflect.deleteProperty(obj, key); // delete obj[key]
Reflect.ownKeys(obj); // all own keys (including Symbols)
Reflect.construct(Cls, args); // new Cls(...args)
Reflect.apply(fn, thisArg, args); // fn.call(thisArg, ...args)
You won’t use these often in regular code. They show up paired with Proxy.
Pitfalls of Proxy #
Proxy is powerful but has costs.
- Performance — every property access invokes a trap function; can be slow inside hot loops
- Autocomplete — static analysis suffers, IDE struggles to know properties
===comparison —proxy === targetis false. Code that treats them as the same object can break- JSON / serialization — what’s visible depends on traps
Because of these costs, reaching for Proxy directly in application code isn’t recommended. Treat it as a tool that belongs inside libraries.
Wrap-up #
What we covered:
- Symbol — unique, collision-free values; great as object keys
- Symbol keys are invisible to regular iteration / JSON — fit hidden metadata
- Well-known Symbols —
Symbol.iteratorand other language contracts Symbol.for(key)shares via the global registry- Proxy — meta-tool to intercept object operations (read/write/delete, etc.)
- Applications: default-value objects, validation, reactivity, dynamic APIs
Reflectis Proxy’s partner — proceed with the original- Proxy costs — performance, autocomplete, serialization
In the next post (#7 Module System in Depth) — the final post — we cover the differences between CommonJS and ES Modules, module hoisting, and what happens with circular references.