Metaprogramming
What is metaprogramming?
There are many features and code constructs that can be considered as examples of metaprogramming. A simple definition of metaprogramming might be: Metaprogramming is writing programs that read, generate, analyze, modify or transform other programs or themselves while running.
Compilers and
transpilers are examples of metaprograms.
They transform programs or code in one language into code in another language.
Earlier we briefly covered decorators
that are "wrapped around" a function or object to extend (decorate) the functionality of the function or object,
at run-time, without changing the original function or object. Decorators are
not yet available in JavaScript, however, "wrapping" functions around other functions and using proxies (see later this chapter) are already possible in JavaScript.
And earlier we also encountered an example of monkey patching,
i.e. extending a built-in prototype, like Array.prototype.myMethod = function () {...}
, which is also an example of metaprogramming.
It changes the behavior of the Array
class at runtime, while the source code of the built-in Array
class remains unchanged.
Bear in mind however, that monkey patching is generally not recommended.
Another notable example of metaprogramming are macros,
but they do not exist in JavaScript.
The various types of metaprogramming have been classified in two main groups:
- Generative metaprogramming, simply put, the ability of a programming language to generate program code.
- Reflective metaprogramming, simply put, the ability of program code to analyze and/or modify itself or other program code.
Metaprogramming in JavaScript
The eval
function is a type of generative metaprogramming that is available in JavaScript.
The eval
function parses a string as a JavaScript.
const person = { name: 'Jane Doe' }
console.log(`Before eval: ${person.age}`); // logs: "Before eval: undefined"
const key = 'age';
const value = 32;
(() => eval(`person.${key} = ${value}`))();
console.log(`After eval: ${person.age}`); // logs: "After eval: 32"
Technically, eval
generated JavaScript code at runtime, from something that is not code but a string.
Since ES6 (ECMAScript 2015) JavaScript has support for two objects specifically designed for metaprogramming:
the Reflect
and Proxy
objects that will be explained further below.
However, metaprogramming does not necessarily require special features.
Something ordinary as the next example is also (reflective) metaprogramming.
// myObj can modify itself at runtime:
const myObj = {
modifyMySelf(key, value) { myObj[key] = value }
}
// modifying myObj:
myObj.modifyMySelf('prop', "✨");
console.log(myObj.prop); // logs: "✨"
Also using common operators and methods like typeof
, instanceof
, in
,
Object.getPrototypeOf()
, etc. are examples of reflective metaprogramming;
they analyze code. Examining the type or properties of an object at runtime is called
type introspection
or simply introspection. Introspection is a subtype of reflective metaprogramming.
Reflective metaprogramming involves modification next to introspection.
An example of modification was the use of the modifyMySelf
method in the example above.
Another example is functional composition, or higher-order functions,
i.e. wrapping one function with another function in order to "decorate" or enhance the original function,
without altering the original function.
const quotation = (quote) => `“${quote}”`;
function quotationDecorator(quotationFunction, name) {
return function(...args) {
const quotationReturn = quotationFunction(args);
return `${quotationReturn}\n− Quote by: ${name}`;
}
}
const quoteByDescartes = quotationDecorator(quotation, "René Descartes");
console.log(quotation("Cogito, ergo sum.")); // logs: "“Cogito, ergo sum.”"
console.log(quoteByDescartes("Cogito, ergo sum."));
// logs:
// "“Cogito, ergo sum.”
// − Quote by: René Descartes"
const quote = "It is not enough to have a good mind, the main thing is to use it well."
console.log(quoteByDescartes(quote));
// logs:
// "“It is not enough to have a good mind, the main thing is to use it well.”
// − Quote by: René Descartes"
BTW. Note that the above example also creates a closure.
This can also be accomplished by using Proxy
objects, as will be explained later this chapter.
Reflect
Reflect
is a built-in object that provides a number of static methods specifically designed for reflective metaprogramming.
Unlike most global objects, Reflect
cannot be used with a new
operator (it is not a constructor,
just like Math
),
nor can it be invoked as a function. All properties and methods of Reflect
are static.
All Reflect
's methods are reflective functions: They modify or introspect objects and functions at runtime.
Many Reflect
's methods do (about) the same thing as equivalent methods of Object
or Function
.
Under Reflect
they are all organized in one object.
This is a
list of all Reflect
methods on MDN.
In the next example we use Reflect.apply()
as equivalent to
Function.prototype.apply()
.
This method modifies the this
value of a function (without altering the function).
function myFunction(x, y, z) {
return `x = ${x}, y = ${y} and z = ${z}.`
}
const args = [0, 1, 2];
console.log(myFunction.apply(undefined, args)); // logs: "x = 0, y = 1 and z = 2."
console.log(Reflect.apply(myFunction, undefined, args)); // logs: "x = 0, y = 1 and z = 2."
// Both methods act different when an arguments list is omitted:
console.log(myFunction.apply(undefined)); //logs: x = undefined, y = undefined and z = undefined.
Reflect.apply(myFunction, undefined); // TypeError: `argumentsList` argument of Reflect.apply must be an object, got (void 0)
Suppose that myFunction
in the example above gets its own apply()
method.
In that case, this own apply()
method will shadow Function.prototype.apply()
, while
Reflect.apply()
will still work.
However, the main use case for Reflect
is to provide default forwarding behavior in Proxy
handler functions, as explained in the next section.
Proxy
A proxy
object is an object instance created by using the Proxy()
constructor (constructed with new
).
The proxy object can be used in place of a target object. The proxy can intercept and redefine fundamental operations for that target object.
The Proxy()
constructor takes two parameters:
target
: the target object which you want to proxy.handler
: an object that defines the operations to intercept and how to redefine them.
The handler methods are also called traps. Here is a
list of all "traps" or
handler functions on the MDN website.
The next example uses handler method handler.get()
.
const target = {
greeting: "Hi, how y'all Doin'?",
message: "How was your trip?",
};
const handler = {
get(target, prop, receiver) {
if (prop === 'greeting') {
return `${target.greeting.substr(0, 3)} nice to meet you.`;
}
// Execute the default introspection behavior:
return Reflect.get(target, prop, receiver);
},
};
const proxy = new Proxy(target, handler);
console.log(target.greeting); // logs: "Hi, how y'all Doin'?"
console.log(target.message); // logs: "How was your trip?"
console.log(proxy.greeting); // logs: "Hi, nice to meet you."
console.log(proxy.message); // logs: "How was your trip?"
BTW: The use of Reflect.get(target, prop, receiver)
in the example above, will be explained a little later.
Handler functions are called "traps" because they trap, or intercept and redefine fundamental operations on the target object.
The handler.get()
method "traps" the regular operation of getting property values.
In the example above it intercepts the dot-notation and redefines its original return value.
The next example uses handler.set()
that intercepts property accessors (dot-notation and square bracket notation) and redefines the default setting of properties.
const proxy = new Proxy(
{},
{
set() {
console.log('You cannot set properties on this object.');
},
},
);
proxy.someProp = 2; // logs: "You cannot set properties on this object."
console.log(proxy.someProp); // logs: undefined
Default forwarding
The proxy is not an adapted copy nor a "decoration" of the target. The proxy is an object with always precisely two own properties:
a target object and a handler object. Trapped operations performed on the proxy that are intended to modify
the target will actually modify the target, not the proxy.
However, we can, for instance, access properties of the target via the proxy, like proxy.targetProperty
.
This is because a proxy will, by default, forward all applied operations to the target object.
Only operations for which the handler has a trap are intercepted.
const obj = {};
const hndlr = {}
const proxy = new Proxy(obj, hndlr);
console.info(proxy); // logs: Proxy { <target>: {}, <handler>: {} }
const target = { prop: "target property" };
const handler = {};
const proxy = new Proxy(target, handler);
console.log(proxy.prop); // log: "target property"
console.log(target.prop); // log: "target property"
console.log(proxy.prop === target.prop); // logs: true
delete proxy.prop;
console.log(proxy.prop); // log: undefined
console.log(target.prop); // log: undefined
proxy.prop = "proxy property";
console.log(proxy.prop); // log: "proxy property"
console.log(target.prop); // log: "proxy property"
console.info(proxy); // logs: Proxy { <target>: {…}, <handler>: {} }
Object.assign(proxy, { prop2: "new prop" });
Object.assign(target, { prop3: "brand new prop" });
console.log(target); // logs: { prop: "proxy property", prop2: "new prop", prop3: "brand new prop" }
console.log(`'${proxy.prop2}' and '${proxy.prop3}'`); // logs: "'new prop' and 'brand new prop'"
Reflect
in Proxy
The Reflect
object is often used in handler functions in proxies to invoke the default behavior of the trapped operation.
The Reflect
object provides methods with the same names and parameters as the Proxy
traps.
This allows you to use similar syntax instead of a regular operation, which might be more convenient.
const target = {
greeting: "Hi, how y'all Doin'?",
message: "How was your trip?",
};
const handler = {
get(target, prop, receiver) {
if (prop === 'greeting') {
return `${target.greeting.substr(0, 3)} nice to meet you.`;
}
// Execute the default introspection behavior:
// return target[prop]; // or:
return Reflect.get(target, prop, receiver);
},
};
const proxy = new Proxy(target, handler);
Also, it is not always immediately clear what to return without using Reflect
methods.
For instance, what to return on a set
handler? No returning at all will work with dot-notations (in non-strict mode) but will corrupt
methods like Object.assign()
. You will need to return true
to make it all work correctly.
The requirement that, in strict mode, the set()
handler needs to return a true
value
is called an invariant for that trap.
The handler's invariants are the semantics that need to remain unchanged when implementing that handler.
If a trap implementation violates the invariants of a handler, a TypeError
will be thrown.
Using Reflect
methods makes it easier to obey the handler's invariants.
"use strict";
const obj = { a: 1, b: 2 };
const proxy = new Proxy(
obj,
{
set(t, p, v, r) {
p = "prefix_" + p;
t[p] = "static value";
// return Reflect.get(t, p, v, r); // or:
return true;
},
},
);
obj.c = 3;
console.log(obj);
// logs: { a: 1, b: 2, c: 3 }
proxy.d = 4;
console.log(obj);
// logs: { a: 1, b: 2, c: 3, prefix_d: "static value" }
Object.assign(proxy, { e: 5 });
console.log(obj);
// logs: { a: 1, b: 2, c: 3, prefix_d: "static value", prefix_e: "static value" }
handler.apply()
The handler.apply()
and handler.construct()
are traps for proxies on functions instead of on objects.
Handler handler.apply()
provides an alternative for wrapping a function in another function
in order to "decorate" or enhance the original function, without altering the original function.
const sum = (a, b) => a + b;
const resultTimesTen = {
apply: function(target, thisArg, argumentsList) {
return (Reflect.apply(target, thisArg, argumentsList)) * 10;
// return target(argumentsList[0], argumentsList[1]) * 10; // only works with target functions with just two parameters
},
};
const sumTimesTen = new Proxy(sum, resultTimesTen);
console.log(sum(1, 2)); // logs: 3
console.log(sumTimesTen(1, 2)); //logs: 30
Earlier we provided an example of function wrapping. Next, the same example, but now implemented using a proxy:
const quotation = (quote) => `“${quote}”`;
const attributedQuotation = new Proxy(
quotation,
{
apply(target, thisArg, argumentsList) {
const quote = target(argumentsList[0]);
return `${quote}\n− Quote by: ${argumentsList[1]}`;
},
},
);
const quoteByDescartes = (quote) => attributedQuotation(quote, "René Descartes");
console.log(quotation("Cogito, ergo sum.")); // logs: "“Cogito, ergo sum.”"
console.log(quoteByDescartes("Cogito, ergo sum."));
// logs:
// "“Cogito, ergo sum.”
// − Quote by: René Descartes"
Validation
In the next two examples we use a proxy to validate the passed value of a property when trying to get or set this value.
Proxies can be used on any object. So, they can, for instance, also be used on arrays or on classes. In the next example a proxy is applied to an array.
Ordinary arrays return nullish value undefined
when accessing a non-existing element.
The adapted array in the example below throws an error when trying to access a non-existing element.
const array = ["a", "b"];
const proxy = new Proxy(
array,
{
get(t, p, r) {
if (t[p] == null) {
if (p > t.length - 1) {
throw new RangeError(`There are only ${t.length} elements in this array.`);
}
throw new RangeError("No such key.");
}
// Execute the default introspection behavior:
// return Reflect.get(t, p, r); // or:
return t[p];
},
},
);
console.log(array["a"]); // logs: undefined
console.log(proxy[0]); // logs: "a"
console.log(proxy[5]); // logs: RangeError: There are only 2 elements in this array.
console.log(proxy[-5]); // logs: RangeError: No such key.
console.log(proxy["a"]); // logs: RangeError: No such key.
The second example uses the set()
handler:
const validator = {
set(obj, prop, value) {
if (prop === "age") {
if (!Number.isInteger(value)) {
if (typeof value !== 'number') {
throw new TypeError(`'${value}' is not a number`);
}
throw new TypeError(`${value} is not an integer`);
}
if (value < 0 || value > 130) {
throw new RangeError(`${value} is an invalid age`);
}
}
// return Reflect.set(obj, prop, value); // or:
// The default behavior to store the value:
obj[prop] = value;
// Indicate success:
return true;
},
};
const person = new Proxy({}, validator);
person.age = 100;
console.log(person.age); // logs: 100
person.age = 2.5; // TypeError: 2.5 is not an integer
person.age = Infinity; // TypeError: Infinity is not an integer
person.age = "senior"; // TypeError: 'senior' is not a number
person.age = 150; // RangeError: 150 is an invalid age
person.age = -1; // RangeError: -1 is an invalid age
Example from: MDN web docs - Proxy.
Object internal methods
Under the hood, operations on object properties invoke object internal methods. It is not possible to directly manipulate data stored in an object. Instead, the manipulation automatically occurs through these internal methods.
For example, property access (e.g. myObj.x
) invokes the [[Get]]
internal method that all ordinary objects, by default, have.
The [[Get]]
internal method defines how the property is searched up the prototype chain and what the return value will be.
All interactions with an object internally involve the invocation of one or more of the default internal methods.
The internal methods are not directly accessible, but are called indirectly in JavaScript.
Through proxies we can override the internal methods. Instead of calling an internal method we can let an operation on an object call a customized handler function.
The proxy then intercepts the invocation of the default internal method and redefines a new method to be invoked instead.
In fact, "special" objects or exotic objects such as Array
, are simply
objects whose internal methods have different implementations from ordinary objects.
With Proxy
you can define your own "exotic" objects.
Be aware that internal methods are generally not called by only one method or operation. [[Set]]
will be invoked when setting a property
using the dot-notation, but, for example, also by a method like Object.assign()
, as we have seen earlier.
Revocable proxies
The Proxy.revocable(target, handler)
function returns a plain object with two properties:
a Proxy
object, exactly the same as one created with new Proxy(target, handler)
,
and a revoke
method that can be called to revoke (disable, switch off) the proxy.
const revocableProxyObject = Proxy.revocable(
{prop: "foo"},
{},
);
console.log(revocableProxyObject); // logs: { proxy: Proxy, revoke: () }
revocableProxyObject.proxy.prop; // "foo"
revocableProxyObject.revoke();
const myObj = {foo: 1}
const revocableProxyObject = Proxy.revocable(
myObj,
{
get(target, key) {
return `👉🏾 ${key} 👈🏾`;
},
},
);
const revocableProxy = revocableProxyObject.proxy
revocableProxy.foobar = 5;
console.log(revocableProxy.foobar); // logs: "👉🏾 foobar 👈🏾"
console.log(myObj); // logs: { foo: 1, foobar: 5 }
revocableProxyObject.revoke();
console.log(myObj.foobar); // logs: 5
console.log(revocableProxy.foobar); // TypeError: illegal operation attempted on a revoked proxy
revocableProxy.foobar = 1; // TypeError: illegal operation attempted on a revoked proxy
delete revocableProxy.foobar; // TypeError: illegal operation attempted on a revoked proxy
console.log(typeof revocableProxy); // logs: "object" // typeof doesn't trigger any trap
The revoke
method does not take any parameters.
After the revoke
method gets called, the proxy becomes unusable.
Once a proxy is revoked, it remains permanently revoked.
The main benefit of proxy revocation is effective memory management.
After revocation, if the proxy, target
and handler
are not referenced elsewhere, they will be candidates for garbage collection.