Classes considered harmful when authoring a JavaScript library
Let me start by saying I’m the biggest OOP fan. I love Ruby. I have a copy of POODR on my bookcase. I look back fondly to the times of React.createClass({ mixins: [MyMixin], ... }). I loathe HOCs and hooks and whatever patterns FP zealots have been trying to push for the past couple of years. However, classes and OOP may not always be the best approach.
Also, I’m aware of the “Considered Harmful” Essays Considered Harmful essay – I’m being cheeky with the title.
So with that out of the way, let’s go.
Performance is critical when writing a JavaScript library meant for the browser. There are two main aspects to performance on the web: the speed of the code – the faster, the better – and the size of the code – the smaller, the better. The more code gets included in a website, the more time the browser spends downloading, decompressing, parsing and executing it. So shipping less code to the browser is of the utmost importance.
The problem of using a class to build a JavaScript service is that it leads to the code being larger than it actually needs to be, for two reasons.
1. Classes are not tree-shakeable
That’s right. If you import a class with 20 methods and you only use 1 method everything gets bundled.
Minifiers / compressors such as Terser, SWC and esbuild (which I will simply call “bundlers”) do not tree-shake unused class methods because it is dangerous to do so. For example, a bundler could remove a class method, believing it to be unused, that our code could be calling dynamically with classInstance[dynamicMethodName](). That would result in our code blowing up with an Uncaught TypeError: classInstance.dynamicMethodName is not a function.
Since bundlers cannot determine whether a certain method is used or not, they do not take risks and do not tree-shake class methods.
2. Class properties and methods are not minifiable
For the same reasons that bundlers cannot remove properties and methods, they also cannot rename them.
Our code could be calling Object.keys(classInstance) to get the names of its properties. Or our code could be using classInstance.hasOwnProperty(propertyName) to check for a certain property. So bundlers simply can’t rename these properties and methods as they wish.
The same is true even for private properties and methods. And while there are ways to minify those, a lot can definitely go wrong.
This is a huge issue in the JavaScript tooling ecosystem, as Marvin Hagemeister brilliantly explains:
A recurring problem in various js tools is that they are composed of a couple of big classes that pull in everything instead of only the code you need. Those classes always start out small and with good intentions to be lean, but somehow they just get bigger and bigger. It becomes harder and harder to ensure that you’re only loading the code you need. This reminds me of this quote from Joe Armstrong (creator of the Erlang programming language):
You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
And this problem isn’t exclusive to packages published on NPM. Many companies’ internal codebases have the same issue. As initial requirements change and new requirements are added, these classes mutate and grow. Inevitably this leads to huge (and slow) websites being served to users worldwide.
So what can we do about this? Is there an alternative, better way to author a JavaScript library?
Well, bundlers are already very good at tree-shaking unused exports. If we import and use 1 function from a module that exports 20 functions, only that one function gets bundled.
So when writing a JavaScript service we should avoid exposing its API through classes and public methods. Instead, expose it through named exports!
Instead of this:
// lib
export class Chirpy {
constructor(options) {
this.options = options;
}
chirp(message) {
return fetch(`${this.options.endpoint}/chirp`, {
method: 'POST',
body: JSON.stringify(message),
})
.then(this.parseJson);
}
parseJson(response) {
return response.json();
}
}
// usage
import { Chirpy } from 'chirpy';
const client = new Chirpy(config);
client.chirp('hello');
// lib
let options;
export function initialise(opts) {
options = opts;
}
export function chirp(message) {
return fetch(`${options.endpoint}/chirp`, {
method: 'POST',
body: JSON.stringify(message),
})
.then(parseJson);
}
function parseJson(response) {
return response.json();
}
// usage
import { initialise, chirp } from 'chirpy';
initialise(config);
chirp('hello');
This pattern results in a reduced bundle size due to the two reasons we observed previously. First, “private” functions like parseJson and “private” variables like options are untouchable from outside the module, so bundlers can minify them. And second, module exports that are unused can be tree-shaken by bundlers.
This pattern also gives us the freedom to increase the public API of our library (by adding more exported functions to the module) without having to worry about it affecting our consumers’ bundle sizes. Since all unused exports are tree-shaken, our functions only get included in consumers’ bundles if they get imported.
I call this pattern the Service Module. Use it whenever possible instead of service classes, classes with static methods or singletons. You know, it’s funny, I remember the Module Pattern being discussed a long, long time ago as a way to isolate and organize JavaScript modules, way before the keywords import and export were even part of modern JavaScript. Time to bring it back!
-
With this pattern we can still keep the previous API more or less intact by doing the following:
// usage import * as Chirpy from 'chirpy'; Chirpy.initialise(config); Chirpy.chirp('hello');But
import * as Somethingis not tree-shakeable, so we should avoid using it if we care about our bundle size. ↩ -
A drawback of this pattern is that consumers are no longer able to create two different instances of the same class:
// usage import { Chirpy } from 'chirpy'; const client1 = new Chirpy(config); const client2 = new Chirpy(anotherConfig); client1.chirp('hello'); client2.chirp('hey');But if creating multiple instances of the same class doesn’t make sense for your JavaScript library, then this isn’t an issue. ↩