Standard Library: Metaprogramming

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

Beyond everyday property reads and writes, JavaScript exposes a set of lower-level APIs for inspecting and controlling how objects themselves behave — property attributes, extensibility, and the ability to intercept fundamental operations entirely. These tools are rarely needed in application code, but they are what library authors reach for to build validation layers, ORMs, observability wrappers, and "locked down" public APIs.

Property Attributes and Descriptors

Every own property of an object has, in addition to its name and value, three boolean attributes that control how it behaves:

  • writable — whether the property’s value can be changed by assignment.

  • enumerable — whether the property shows up in for…​in loops and Object.keys()/Object.values()/ Object.entries().

  • configurable — whether the property can be deleted, and whether its attributes (other than value, when non-writable) can be changed at all.

Properties created via object literals or ordinary assignment are writable, enumerable, and configurable by default. Many built-in properties (array methods, class methods) are not — they are typically non-enumerable, so they don’t clutter a for…​in loop or JSON.stringify() output.

These four pieces of information (value, writable, enumerable, configurable for a data property; get, set, enumerable, configurable for an accessor property) are collectively called a property descriptor, and are read and written as a plain object:

// Read a property descriptor
Object.getOwnPropertyDescriptor({ x: 1 }, "x");
// => { value: 1, writable: true, enumerable: true, configurable: true }

// Define a new, non-enumerable property
const o = {};
Object.defineProperty(o, "hidden", {
  value: 42,
  writable: true,
  enumerable: false,
  configurable: true,
});
Object.keys(o); // => [] -- "hidden" doesn't show up
o.hidden; // => 42 -- but it's still there

// Define several properties at once
Object.defineProperties(o, {
  x: { value: 1, writable: true, enumerable: true, configurable: true },
  y: { value: 2, writable: true, enumerable: true, configurable: true },
});

Object.getOwnPropertyDescriptor() only inspects an object’s own properties — it returns undefined for inherited ones. Object.defineProperty() throws a TypeError if the requested change isn’t allowed (e.g. adding a property to a non-extensible object, or changing an attribute on a non-configurable one); the exact rules are subtle enough that library code generally reads the existing descriptor before attempting a change rather than guessing.

Making library-added prototype methods non-enumerable (enumerable: false) is standard practice — it keeps them out of a consumer’s for…​in loops the same way built-in methods like Array.prototype.map already are.

Extensibility: Sealing and Freezing

Independent of individual property attributes, an object as a whole has an extensible attribute that controls whether new properties can be added to it at all:

const o = { x: 1 };
Object.isExtensible(o); // => true
Object.preventExtensions(o);
o.y = 2; // fails silently (or throws in strict mode)
Object.isExtensible(o); // => false

Two higher-level functions combine extensibility with the configurable/writable attributes of every existing property:

Function Effect

Object.seal(o)

Makes o non-extensible and makes every existing own property non-configurable. Existing writable properties can still be reassigned, but nothing can be added, removed, or reconfigured.

Object.freeze(o)

Everything seal() does, plus makes every data property non-writable too. The object becomes fully immutable (accessor properties with setters are unaffected and still work).

Object.isSealed()/Object.isFrozen() report the current state. Note that both functions affect only the object they’re called on — freezing an object does not freeze its prototype, so a thoroughly locked-down object graph needs each level frozen individually.

const config = Object.freeze({ retries: 3, timeout: 5000 });
config.retries = 10; // silently ignored (throws in strict mode)
config.retries; // => 3

Freezing objects passed into callbacks is a convenient way to signal "read-only" to consumers of a library, at the cost of interfering with some testing strategies that rely on mutating fixtures.

Prototypes

The prototype an object inherits from is itself just another (normally hidden) attribute, queryable and, in limited circumstances, settable:

Object.getPrototypeOf({}); // => Object.prototype
Object.getPrototypeOf([]); // => Array.prototype

const base = { greet() { return "hi"; } };
const obj = Object.create(base);
base.isPrototypeOf(obj); // => true

Object.setPrototypeOf(obj, { greet() { return "hello"; } });
obj.greet(); // => "hello"

Object.setPrototypeOf() works, but changing an object’s prototype after creation defeats optimizations most JavaScript engines make on the assumption that an object’s "shape" is stable — prefer Object.create() to set the prototype up front. The legacy proto accessor (readable/writable on every object, for web compatibility) does the same thing and is best avoided in new code, except as a literal-syntax convenience: { …​data, proto: base }.

Well-Known Symbols

A handful of Symbol values act as hooks that let your own classes plug into core language behavior. The two most commonly used:

  • Symbol.iterator — makes an object work with for…​of and the spread operator; covered in Iterators & Generators.

  • Symbol.toPrimitive — lets a class control exactly how it converts to a primitive (string, number, or "default") when used with template literals, arithmetic operators, or ==.

class Money {
  constructor(cents) { this.cents = cents; }
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return this.cents / 100;
    if (hint === "string") return `$${(this.cents / 100).toFixed(2)}`;
    return this.cents / 100; // "default"
  }
}
const price = new Money(1999);
`Total: ${price}`; // => "Total: $19.99"
price * 2; // => 39.98

Other well-known Symbols exist for more specialized needs: Symbol.hasInstance (customizing instanceof), Symbol.toStringTag (customizing what Object.prototype.toString.call(x) reports), Symbol.species (controlling what constructor array-returning methods like map() use on a subclass), and the pattern-matching family (Symbol.match, Symbol.replace, Symbol.search, Symbol.split) that lets a custom class stand in for a RegExp argument to string methods. These are rarely needed outside library code that deliberately mimics a built-in type.

The Reflect API

Reflect is not a class — like Math, it’s a plain object whose properties are a namespaced collection of functions. Each one mirrors a fundamental language operation (Reflect.get, Reflect.set, Reflect.has, Reflect.deleteProperty, Reflect.ownKeys, Reflect.defineProperty, Reflect.getPrototypeOf, Reflect.setPrototypeOf, Reflect.isExtensible, Reflect.preventExtensions, Reflect.apply, Reflect.construct, and a couple more):

const o = { x: 1 };
Reflect.get(o, "x"); // => 1, same as o.x
Reflect.set(o, "y", 2); // => true, same as (o.y = 2)
Reflect.has(o, "x"); // => true, same as "x" in o
Reflect.ownKeys(o); // => ["x", "y"]

Reflect doesn’t add new capability — everything it does is achievable another way (mostly via Object. or operator syntax) — but it groups the operations into one consistent, function-call API, with more predictable return values than their Object. counterparts (e.g. Reflect.defineProperty() returns true/false instead of throwing or returning the object). Its real purpose is to serve as the default-behavior counterpart to Proxy traps below: every Proxy handler method has a name and signature that matches a Reflect function one-to-one, which makes "do the normal thing" trivial to express inside a trap.

The Proxy API

Proxy is JavaScript’s most powerful metaprogramming tool: it lets you intercept and redefine the fundamental operations performed on an object — property reads, writes, deletion, enumeration, and more.

const proxy = new Proxy(target, handlers);

target is the underlying object; handlers is an object whose methods ("traps") are invoked instead of the corresponding operation on target. Any trap you omit falls through to the real operation on target — an empty handlers object produces a transparent pass-through wrapper.

Worked example: a validating proxy

A common use is guarding writes to an object — for example, rejecting assignments that violate a schema:

function createValidated(target, schema) {
  return new Proxy(target, {
    get(obj, prop, receiver) {
      console.log(`read ${String(prop)}`);
      return Reflect.get(obj, prop, receiver);
    },
    set(obj, prop, value, receiver) {
      const validator = schema[prop];
      if (validator && !validator(value)) {
        throw new TypeError(`Invalid value for "${String(prop)}": ${value}`);
      }
      return Reflect.set(obj, prop, value, receiver);
    },
  });
}

const user = createValidated(
  { name: "Ada", age: 30 },
  { age: (v) => Number.isInteger(v) && v >= 0 },
);

user.name; // logs "read name", => "Ada"
user.age = 31; // ok
user.age = -5; // !TypeError: Invalid value for "age": -5

The get trap logs and then delegates via Reflect.get() (using the same target/property/receiver arguments the trap itself received, so inherited accessors still work correctly); the set trap validates before delegating to Reflect.set(), and simply doesn’t delegate at all when validation fails.

Revocable proxies

Proxy.revocable() returns a { proxy, revoke } pair. Calling revoke() permanently disables the proxy — useful for handing untrusted code a reference you can cut off later, without that code being able to keep working around the revocation:

const { proxy, revoke } = Proxy.revocable(realApi, {});
grantAccessTo(thirdPartyPlugin, proxy);
// ...later, if the plugin misbehaves:
revoke();
proxy.someMethod(); // !TypeError: proxy has been revoked

Invariants

Proxy doesn’t let a trap say anything it wants — the engine still enforces core JavaScript invariants (e.g. a get trap on a non-configurable, non-writable property must return that property’s real value; an isExtensible trap on a non-extensible target must return false). Violating one throws a TypeError from the proxy itself, which keeps Proxy-based abstractions from producing internally inconsistent objects even when a handler is buggy.

When to Reach for These APIs

Most application code never needs Object.defineProperty(), Reflect, or Proxy directly — they exist for framework and library authors building things like reactive state systems (a set trap that triggers UI updates), ORMs (a get trap that lazily loads related records), immutable-data helpers, or test doubles that need to observe every interaction with an object. Reaching for class with plain getters/setters (Classes) covers the vast majority of "control how a property behaves" needs with far less complexity.