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:

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:

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.