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 infor…inloops andObject.keys()/Object.values()/Object.entries(). -
configurable— whether the property can be deleted, and whether its attributes (other thanvalue, 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 |
|---|---|
|
Makes |
|
Everything |
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 withfor…ofand 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.