Extending native JS prototypes is not such a crazy idea with symbols

slikts

Reinis Ivanovs

Posted on July 20, 2023

Extending native JS prototypes is not such a crazy idea with symbols

Dynamic languages like JavaScript have something called "open classes" that allow to extend or monkey patch code at runtime, including the standard built-in objects, and this has always tempted users to try and improve the language with custom methods that are in the shared global namespaces like Array.prototype instead of their own, because it's just convenient that objects come "batteries included" without having to be wrapped in anything and even if they're constructed using literal syntax. To illustrate, you could look at all the historical Stack Overflow answers about flattening arrays before Array.prototype.flat() was added in 2019, because all of them come with different tradeoffs for something that should be a very basic language feature.

Workaround #1 for missing built-in methods: helpers 🛠️

It's no big deal, right? Just abstract flattening to a helper function:

function flatten(arr) {
  return [].concat.apply([], arr);
}
Enter fullscreen mode Exit fullscreen mode

Except this comes with a host of practical, performance and aesthetic issues, starting from the fact that it's not chainable like other array methods and you need intermediate variables, which you can fix by making it a callback to .reduce(), but then it's making N function calls instead of just one like before, etc. It's still no big deal, but the inconvenience adds up, including if you're using helper libraries like Lodash where a lot of work has already been done for you.

Workaround #2: wrappers 🎁

Wrappers step up the complexity, but improve on just helper functions because they allow method chaining, with the most classic one being jQuery's $(). The nice thing about wrappers is that they can namespace methods and editors can show context-aware autocompletion for them, but their Achilles heel is the need to wrap and especially unwrap values, and also that wrapping or converting hides the previous identity so you can't do simple identity comparisons etc. All in all, the fact that even high-profile projects like Immutable.js have limited adoption testify to wrappers just not being convenient enough.

Workaround #3: function composition and pipelining 🪠

An alternative to method chaining or the so-called fluent interfaces is pipelining, and, for example, Lodash provides it as _.pipeline(). This is the functional approach, and functional languages have built-in support for it, but even though JavaScript is a multi-paradigm language and has functional elements, pipelining is just not idiomatic in JavaScript, while chaining is. This is illustrated by the difficulties the pipeline operator proposal has faced in getting standardized, even though it's on many developer's wish list.

Workaround #4: native prototype extension 🏗️

This circles back to the beginning of the article with array flattening as a missing standard feature, because around 2006, when the first JavaScript libraries were just appearing, one of them went and fixed this issue by implementing their own Array.prototype.flatten, and it also tried to "play nice" and not overwrite the method if it already existed, which ironically ensured that code relying on their custom implementation would break when a standard one was added and didn't behave the same. This all caused an event called SmooshGate, and similar situations are responsible for why some of the other method names in JavaScript are also somewhat odd due to more obvious names being "taken".

In fact, the very first widely adopted JavaScript library was called Prototype.js, and one of its selling points was extending the DOM APIs so that DOM Elements would come "batteries included", but it got out-competed by jQuery largely because people realized that native prototype extension is dangerous in that it's fragile and hard to maintain compared to wrappers.

Since then, monkey patching the built-in objects has been a long-standing discussion, with some people still trying to cling on to its promise, but overall the consensus is that it's a bad practice that should be reserved to polyfilling, and that you should only modify objects that you own.

Solution: symbol protocol extension 🤯

The concept of protocols is already in use in JavaScript, most notably as as iteration protocols, and there is even a proposal to add first-class support for symbol-based protocols. I've also made a proof-of-concept library (Symbola) for implementing symbol extension in userland, and it actually works nicely, with some caveats.

Something that should lend legitimacy to this approach is, firstly, that it aligns with similar language features like Swift and Clojure protocols, Rust traits, Haskell typeclasses and others, and, secondly, that it has theoretical grounding in that it solves the expression problem. In a nutshell, the expression problem is when you want to both be able to easily extend existing methods to new data types, and to extend new data types with existing methods, all without modifying the source code, and also while retaining type safety.

The extension problem is not just an academic exercise, because there's real pragmatic reasons why you are not able to or don't want to modify code but want to extend it, and not owning the code is one of those reasons. This is why, in a sense, the "open classes" and monkey patching in JavaScript are a fix to the expression problem, but you're not supposed to use it because it leads to fragility, namespace collisions and maintenance problems.

Symbols to the rescue 📯

Symbols are unique and can be used as keys instead of strings while using the same namespace like, for example, Array.prototype, and define a flatten method that can be accessed only with reference to the unique symbol:

const flatten = Symbol('flatten');

Array.prototype[flatten] = function() { return [].concat.apply([], this); };

[[1, 2], [3]][flatten](); // returns [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Using interface merging in TypeScript also allows this to be well-typed, and the really interesting things start when building on other protocols:

const map = Symbol('map');

Object.prototype[map] = function*(f) {
  for (const x of this) { yield f(x) }
}

const xs = new Set([1, 2, 3]);
console.log(...xs[map](x => x + 1); // logs [2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

Suddenly the Set objects have a map method, but so does every object that inherits from Object.prototype and conforms the iterable protocol, namely has a [Symbol.iterator]() method that returns an iterator, and this, too, is easy to express in TypeScript:

interface  Mappable  {
  [map]<A,  B>(this:  Iterable<A>):  Iterable<B>
}
declare  global  {
  interface  Object  extends  Mappable  {}
}
Enter fullscreen mode Exit fullscreen mode

This means that editors can know to auto-complete the [map]() method correctly, and the type system will check if the target is actually an iterable, so the support for it is first class.

In conclusion, the biggest caveat of this approach is probably the unfamiliarity to developers, but it does have a valid use case, and JavaScript and TypeScript so far just seem behind other languages in exploring it.

See also

💖 💪 🙅 🚩
slikts
Reinis Ivanovs

Posted on July 20, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related