DEV Community

Cover image for JavaScript Metaprogramming: 10 Proxy, Reflect & Symbol Techniques for Dynamic APIs
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

JavaScript Metaprogramming: 10 Proxy, Reflect & Symbol Techniques for Dynamic APIs

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I remember the first time I truly understood metaprogramming in JavaScript. It was like discovering that your code can write itself. Metaprogramming means your program can inspect, modify, and even create other code while it runs. For building dynamic frameworks and APIs, this is a superpower. Instead of writing endless boilerplate, you can design systems that adapt automatically. JavaScript gives us three native tools for this: Proxy, Reflect, and Symbol. Each one works like a magic wand – but once you see the trick, it becomes straightforward.

Let me walk you through ten techniques I use regularly. I’ll keep the language simple, and I’ll show you real code. By the end, you’ll be able to build your own validation layers, reactive state, API wrappers, and even mini languages – all without bloating your codebase.


1. Proxy for validation

A Proxy object sits between you and a target object. It intercepts operations like reading or writing properties. The simplest use is to enforce rules when someone sets a property. I often build a validation proxy that checks types and ranges.

class ValidationProxy {
  constructor(target, schema) {
    this.schema = schema;
    return new Proxy(target, {
      set: (obj, prop, value) => {
        const constraint = this.schema[prop];
        if (!constraint) {
          obj[prop] = value;
          return true;
        }
        if (constraint.type && typeof value !== constraint.type) {
          throw new TypeError(`Property "${prop}" must be a ${constraint.type}`);
        }
        if (constraint.min !== undefined && value < constraint.min) {
          throw new RangeError(`"${prop}" must be at least ${constraint.min}`);
        }
        if (constraint.max !== undefined && value > constraint.max) {
          throw new RangeError(`"${prop}" must be at most ${constraint.max}`);
        }
        if (constraint.enum && !constraint.enum.includes(value)) {
          throw new Error(`"${prop}" must be one of: ${constraint.enum.join(', ')}`);
        }
        obj[prop] = value;
        return true;
      },
    });
  }
}

const user = new ValidationProxy(
  { name: '', age: 0 },
  { name: { type: 'string' }, age: { type: 'number', min: 0, max: 150 } }
);
user.name = 'Alice'; // works
user.age = 25;       // works
// user.age = -5;    // throws RangeError
Enter fullscreen mode Exit fullscreen mode

This pattern keeps your data clean without duplicating checks everywhere. I use it for configuration objects and API payloads.


2. Reflect for default behavior

When you use a Proxy, you often still want the original behavior for most operations. The Reflect object provides default implementations for every Proxy trap. By calling Reflect.get or Reflect.set inside a trap, you keep the standard logic while adding your own twist. Here’s a virtual array that lazily computes values:

function createLazyArray(length, factory) {
  const handler = {
    get(target, prop) {
      if (prop === 'length') return length;
      if (prop in target) return Reflect.get(target, prop);
      const index = parseInt(prop, 10);
      if (!isNaN(index) && index >= 0 && index < length) {
        if (!(prop in target)) {
          target[prop] = factory(index);
        }
        return target[prop];
      }
      return undefined;
    },
  };
  return new Proxy({}, handler);
}

const fib = createLazyArray(100, (i) => {
  if (i < 2) return i;
  return fib[i - 1] + fib[i - 2];
});
console.log(fib[10]); // 55 – computed only when accessed
Enter fullscreen mode Exit fullscreen mode

Using Reflect saves you from reinventing property lookup. It also makes your traps more predictable.


3. Symbols for internal metadata

Symbols are guaranteed unique property keys. They are perfect for hiding framework internals from user code. For example, if you build an observable that stores listeners and a version counter, those properties should not appear in Object.keys() or JSON.stringify(). I use symbols to keep them private.

const LISTENERS = Symbol('listeners');
const VERSION = Symbol('version');

class Observable {
  constructor(value) {
    this[LISTENERS] = new Set();
    this[VERSION] = 0;
    this._value = value;
  }

  get value() { return this._value; }
  set value(v) {
    if (this._value !== v) {
      this._value = v;
      this[VERSION]++;
      this[LISTENERS].forEach(fn => fn(v));
    }
  }

  subscribe(fn) {
    this[LISTENERS].add(fn);
    return () => this[LISTENERS].delete(fn);
  }

  // make the object iterable over snapshots
  *[Symbol.iterator]() {
    let snapshot = this[VERSION];
    yield this._value;
    while (true) {
      if (this[VERSION] !== snapshot) {
        snapshot = this[VERSION];
        yield this._value;
      }
      yield new Promise(r => setTimeout(r, 0));
    }
  }

  toJSON() { return { value: this._value }; }
}
Enter fullscreen mode Exit fullscreen mode

I also use well-known symbols like Symbol.hasInstance to control instanceof checks when designing custom types.


4. Revocable proxies for security

Sometimes you need to grant temporary access to an object, then revoke it. Proxy.revocable returns a proxy together with a revoke function. Once revoked, any operation on the proxy throws. I used this for a real‑time API that expires after authentication tokens become invalid.

function createTimedAPI(api, expiry) {
  const { proxy, revoke } = Proxy.revocable(api, {
    get(target, prop, receiver) {
      if (Date.now() > expiry) throw new Error('API expired');
      return Reflect.get(target, prop, receiver);
    },
    apply(target, thisArg, args) {
      if (Date.now() > expiry) throw new Error('API expired');
      return Reflect.apply(target, thisArg, args);
    },
  });
  setTimeout(revoke, expiry - Date.now());
  return proxy;
}

const api = createTimedAPI({ fetch: () => 'data' }, Date.now() + 5000);
console.log(api.fetch()); // works
// after 5 seconds: api.fetch() throws
Enter fullscreen mode Exit fullscreen mode

This is cleaner than checking expiry everywhere. The proxy does the guarding for you.


5. Reactive state with computed properties

Imagine you have a state object, and you want derived values to update automatically when underlying properties change. Proxy traps can track dependencies.

function createReactive(initial) {
  const deps = new Map(); // property -> set of computed functions
  let currentComputed = null;

  const handler = {
    get(target, prop) {
      if (currentComputed) {
        if (!deps.has(prop)) deps.set(prop, new Set());
        deps.get(prop).add(currentComputed);
      }
      return Reflect.get(target, prop);
    },
    set(target, prop, value) {
      const old = target[prop];
      if (old !== value) {
        Reflect.set(target, prop, value);
        if (deps.has(prop)) {
          deps.get(prop).forEach(fn => fn());
        }
      }
      return true;
    },
  };

  const state = new Proxy(initial, handler);

  function computed(fn) {
    currentComputed = fn;
    const result = fn();
    currentComputed = null;
    return result;
  }

  return { state, computed };
}

const { state, computed } = createReactive({ a: 1, b: 2 });
const sum = computed(() => state.a + state.b); // 3
state.a = 10; // sum would be recalculated if we had an effect system
Enter fullscreen mode Exit fullscreen mode

With this, you can build a tiny reactive framework. Every time a property changes, all computations that depend on it automatically re‑run.


6. Symbol.toPrimitive for custom type coercion

When you write +myObject or myObject + '', JavaScript calls valueOf or toString internally. You can override this completely with Symbol.toPrimitive. I created a SafeInteger class that throws when someone tries to use it in an unsafe context, but still behaves like a number in math.

class SafeInteger {
  constructor(value) {
    if (!Number.isInteger(value)) throw new Error('Must be integer');
    this.value = value;
  }

  add(other) { return new SafeInteger(this.value + other.value); }

  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.value;
    if (hint === 'string') return `SafeInteger(${this.value})`;
    return this.value;
  }
}

const a = new SafeInteger(5);
const b = new SafeInteger(3);
console.log(a.add(b).multiply(new SafeInteger(2))); // 16
console.log(+a); // 5
console.log(`${a}`); // "SafeInteger(5)"
Enter fullscreen mode Exit fullscreen mode

This makes your custom objects feel native.


7. Proxying built‑in objects

You don’t have to subclass Map or Array to extend their behavior. Wrap them in a Proxy. I once needed a Map that automatically saves to localStorage. The proxy intercepts set, delete, and clear to persist the whole map.

function createPersistentMap(key) {
  const initial = JSON.parse(localStorage.getItem(key) || '{}');
  const map = new Map(Object.entries(initial));
  const handler = {
    get(target, prop) {
      if (prop === 'set') {
        return function(k, v) {
          target.set(k, v);
          persist();
          return this;
        };
      }
      if (prop === 'delete') {
        return function(k) {
          const result = target.delete(k);
          persist();
          return result;
        };
      }
      if (prop === 'clear') {
        return function() {
          target.clear();
          persist();
        };
      }
      const value = Reflect.get(target, prop);
      return typeof value === 'function' ? value.bind(target) : value;
    },
  };
  function persist() {
    localStorage.setItem(key, JSON.stringify(Object.fromEntries(map)));
  }
  return new Proxy(map, handler);
}
Enter fullscreen mode Exit fullscreen mode

Now every change is automatically saved. No manual sync needed.


8. Memoization with Proxy apply

The apply trap captures function calls. I use it to build a generic memoizer that caches results by arguments.

function memoize(fn) {
  const cache = new Map();
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) {
        console.log('from cache');
        return cache.get(key);
      }
      const result = Reflect.apply(target, thisArg, args);
      cache.set(key, result);
      return result;
    },
  });
}

const expensive = memoize((x) => {
  console.log('computing...');
  return x * x;
});

console.log(expensive(4)); // computing... 16
console.log(expensive(4)); // from cache 16
Enter fullscreen mode Exit fullscreen mode

You can extend this to add retry logic, rate limiting, or logging without touching the original function.


9. Dynamic API wrappers

A Proxy’s get trap can generate methods on the fly. This is perfect for creating a REST client where any property name becomes an endpoint.

function createAPI(base) {
  const methods = new Proxy({}, {
    get(target, prop) {
      return function(...args) {
        let url = `${base}/${prop}`;
        const options = {};
        for (const arg of args) {
          if (typeof arg === 'string') {
            url += `/${arg}`;
          } else if (arg?.params) {
            url += '?' + new URLSearchParams(arg.params).toString();
          } else if (arg?.body) {
            options.method = 'POST';
            options.body = JSON.stringify(arg.body);
          }
        }
        return fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }).then(r => r.json());
      };
    },
  });
  return methods;
}

const api = createAPI('https://api.example.com');
api.users({ params: { page: 1 } }); // GET /users?page=1
api.users('123');                     // GET /users/123
api.users({ body: { name: 'Alice' } }); // POST /users
Enter fullscreen mode Exit fullscreen mode

This technique eliminated all my endpoint boilerplate. Every method call becomes a request.


10. Domain‑specific languages with Proxy chaining

When you chain property accesses like v.name.required.min(5), you are building a little language. Proxy can capture this chain and turn it into validation rules.

function createValidator() {
  const rules = [];

  function validate(obj) {
    const errors = [];
    for (const { path, check, message } of rules) {
      const value = path.reduce((s, key) => (s != null ? s[key] : undefined), obj);
      if (!check(value)) errors.push(message);
    }
    return errors;
  }

  const handler = {
    get(target, prop) {
      const chain = [prop];
      const child = new Proxy({}, {
        get(t, next) {
          if (next === 'required') {
            rules.push({ path: chain, check: v => v !== undefined && v !== null && v !== '', message: `${chain.join('.')} is required` });
            return child;
          }
          if (next === 'min') {
            return (min) => {
              rules.push({ path: chain, check: v => typeof v === 'number' && v >= min, message: `${chain.join('.')} must be >= ${min}` });
              return child;
            };
          }
          if (next === 'email') {
            rules.push({ path: chain, check: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), message: `${chain.join('.')} is not a valid email` });
            return child;
          }
          chain.push(next);
          return child;
        },
      });
      return child;
    },
  };

  const v = new Proxy({}, handler);
  v.validate = validate;
  return v;
}

const v = createValidator();
v.name.required;
v.age.min(18);
v.email.email;

const errors = v.validate({ name: '', age: 17, email: 'bad' });
console.log(errors);
// ["name is required", "age must be >= 18", "email is not a valid email"]
Enter fullscreen mode Exit fullscreen mode

I built a whole form validation library using this pattern. It reads almost like English, and it’s easy to extend with new rules.


Each of these ten techniques gives you a way to write code that adapts, guards, and simplifies itself. You don’t need a heavy framework – just Proxy, Reflect, and Symbol. Start with one pattern, like validation or memoization, and see how much boilerplate disappears. Metaprogramming isn’t magic; it’s just a more powerful way to think about code. And once you get comfortable, you’ll never look at plain objects the same way again.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)