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).

creating Symbols
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:

Symbol keys
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 #

iteration and Symbols
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.

Symbol.iterator revisited
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.

Symbol.toPrimitive
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.

Symbol.for / Symbol.keyFor
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.

basic Proxy
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) #

trapTriggered by
getreading a property (obj.a)
setwriting a property (obj.a = 1)
has'a' in obj
deletePropertydelete obj.a
ownKeysObject.keys, Reflect.ownKeys
applyfunction call (fn())
constructnew fn()

There are about 13 traps in total. You can intercept nearly every object operation.

Proxy in practice #

1) Default-value object #

default for missing keys
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 #

value validation
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.

reactive — skeleton
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 #

dynamic API via property access
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.

Reflect.get / set
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:

commonly used Reflect
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.

  1. Performance — every property access invokes a trap function; can be slow inside hot loops
  2. Autocomplete — static analysis suffers, IDE struggles to know properties
  3. === comparisonproxy === target is false. Code that treats them as the same object can break
  4. 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.iterator and 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
  • Reflect is 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.

X