This page looks best with JavaScript enabled

Comprehensive Guide to Metaprogramming in JavaScript

 ·  ☕ 7 min read  ·  ✍️ Iskander Samatov

metaprogramming-in-javascript


Intro

Metaprogramming is the process of writing code that manipulates, generates, or analyses your application’s code. The goal is to alter the default language behavior and make your code more expressive and flexible.

In this article, we will explore metaprogramming in JavaScript and how we can use it to our advantage to write clear, efficient code.

Introspection

Let’s start with simple metaprogramming features JavaScript provides, which will probably seem familiar.

One of the most common metaprogramming tasks is introspection. Introspection is when we need to find out the type of the variable in order to apply appropriate operations to it. In JavaScript, the three most common tools for introspection are toString(), instanceof, and typeof.

toString()

When you call toString() for a given object, it will return that object’s string representation. The reason for this method is to allow you to override it in your derived objects to provide a custom type conversion logic. Under the hood, JavaScript calls this method whenever it needs to convert an object to a primitive:

1
2
3
4
5
6
7
8
9
function Pet(name) {
  this.name = name;
}
const pet = new Pet("Bubbles");
pet.prototype.toString = function petToString() {
  return `${this.name}`;
};
console.log(pet.toString());
// "Bubbles"

instanceof

instanceof checks whether an object has a prototype of the constructor function it is comparing it to, anywhere in its prototype chain.

1
2
3
4
5
6
7
8
function Pet(name) {
  this.name = name;
}
const pet = new Pet(Bubbles);
console.log(pet instanceof Pet);
// true
console.log(pet instanceof Object);
// true

typeof

typeof is a unary operator that returns the type of entity it receives.

1
2
3
4
5
6
console.log(typeof 17);
// "number"
console.log(typeof "Bubbles");
// "string"
console.log(typeof true);
// "boolean"

These are some of the basic metaprogramming features JavaScript provides.

Monkey patching

Monkey patching is a type of metaprogramming. It is a technique to add, modify, or suppress the default behavior of a piece of code at runtime without changing its original source code. Let’s take a look at an example.

JavaScript’s built-in String object provides a lot of functionality out of the box. But some niceties are not available from the String interface alone. For example, say we want to be able to remove white spaces from strings. We can use monkey patching to augment the String constructor:

1
String.prototype.removeSpaces = function() { a.split(' ').join('') }

And voila, now we can remove spaces from our strings!

1
"My spacious string".removeSpaces(); // "Myspaciousstring"

A word of caution: while the example above might seem neat(we’re adding another useful function to our strings, right?), monkey patching is generally considered a dangerous practice, as it can make your code unpredictable. For the example above, a better approach would be to turn removeSpaces into a separate utility method that you can call when needed.

With that said, just like any other programming technique, monkey patching does have its uses. Monkey patching can help fix a specific, known problem in a 3rd-party library rather than waiting for a formal release of a patch that may take too long to come around or might not come around at all if the library is no longer supported.

Function names

Another area of metaprogramming in JavaScript is inspecting function names. There are various cases when you might want to check the function name before deciding what to do with it.

The lexical name of a function is set as the name property by default. That name property is not writable by default, so you’ll need to use the Object.defineProperty() method to overwrite it.

A function’s name is used in error stack traces. When a function doesn’t have a name, the browser will display a generic placeholder such as “(anonymous function)”. Stack traces become less useful when this happens, so naming functions is a good idea, especially if you’re debugging.

Well-known Symbols

Symbols are primitives created using the Symbol constructor and are used as property keys in objects. The main advantage of using symbols is that they are guaranteed to be unique throughout the lifetime of the program, so they will never collide with other keys.

In addition to symbols that you can define in your program, the Symbol constructor has several static fields which are symbols themselves. These particular symbols are called “well-known symbols” and are exposed to let you modify the default behavior of the JavaScript language.

Let’s cover a couple of well-known symbols in more detail.

Symbol.hasInstance

Remember how we used the instanceof operator earlier? Well, actually, you can customize the behavior of that operator using Symbol.hasInstance:

1
2
3
4
5
6
7
class MyArray {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}
console.log([] instanceof MyArray); // true

In the above code, we are overwriting the default behavior of the instanceof operator for our custom MyArray class.

Symbol.toPrimitive

Symbol.toPrimitive is another well-known symbol in JavaScript. It specifies a method that accepts the type of object you need and returns its primitive representation. JavaScript uses this when converting objects to primitives.

1
2
3
4
5
6
7
8
9
const object1 = {
  [Symbol.toPrimitive](hint) {
    if (hint === "number") {
      return 16;
    }
    return null;
  },
};
console.log(+object1); // 16

Here we covered only a couple of well-known symbols. Follow this link to check out the complete list of well-known symbols available.

Proxies

A proxy is a specific type of object that wraps target objects. And instead of directly interacting with the target object, your client interacts with the proxy.

When creating a proxy, you can define specific handlers, called traps, that are triggered when various operations are performed against the target. These traps can be used to intersect a wide variety of basic operations.

Proxies provide powerful metaprogramming benefits. They let you intercept and override behavior of objects without altering them. Let’s take a look at a simple example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const pet = {
  species: "cat",
  name: "Bubbles",
};
const handler = {
  get(target, prop, receiver) {
    return "dog";
  },
};
const proxy = new Proxy(pet, handler);
console.log(proxy.name); // dog
console.log(proxy.species); // dog

In the code above, we used a proxy to change the values you get when accessing fields of the pet object. We did this by adding a trap that overwrites the object’s get handler.

Here’s the complete list of handlers available with proxies .

Reflect

A keen eye might notice that the behavior of our proxy above is flawed. No matter which field we try to access, we always get the hardcoded dog string as a value. We can fix this with Reflect.

Reflect is a plain JavaScript object that contains static functions which match various metaprogramming tasks you can control. These functions correspond one-to-one with the handler methods (traps) that proxies can define. The primary use of Reflect API is to forward actions called on proxy traps, and for that reason, Reflect typically goes hand in hand with proxies.

Going back to our proxy example, we can use Reflect to make it so that we return the dog string only when the client is trying to access the species field:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const pet = {
  species: "cat",
  name: "Bubbles",
};
const handler = {
  get(target, prop, receiver) {
    if (prop === "species") {
      return "dog";
    }
    return Reflect.get(...arguments);
  },
};
const proxy = new Proxy(pet, handler);
console.log(proxy.name); // Bubbles
console.log(proxy.species); // dog

Check out this page to get the comprehensive list of the static fields available via the Reflect object.

Conclusion

In this article, we covered the main tools for metaprogramming in JavaScript.

Metaprogramming is a powerful technique for extending the capabilities of a language. However, as with any programming tool, use good judgment. Overusing metaprogramming to overwrite the default behavior of JavaScript language can result in over-engineered code that’s difficult to understand.

If you’d like to get more web development, React and TypeScript tips consider following me on Twitter, where I share things as I learn them.
Happy coding!

Share on

Software Development Tutorials
WRITTEN BY
Iskander Samatov
The best up-to-date tutorials on React, JavaScript and web development.