JavaScript private class fields can reduce bundle size
The private
keyword from TypeScript is removed on compilation. Therefore any field or property that's marked as private
becomes public in the final JavaScript code.
Public properties can not be renamed safely by a minifier since it can't guarantee that the property isn't being accessed, using the original name, at runtime.
JavaScript private class fields, which are part of ES2022, can only ever be accessed inside the class in which they are defined. This makes it possible to minify or mangle the names inside the class since the minifier can be sure that no one from the outside relies on the name being stable.
Let's have a look at this in action. We will use the following code for demonstration:
class MyClass {
private _propertyOne = 1;
private _propertyTwo = 2;
printProperties() {
console.log(this._propertyOne, this._propertyTwo);
}
}
new MyClass().printProperties();
This code is fairly representative of a "real world" use case. We have a class with three properties. Two of which are fields that have the private
keyword in front. The other one is a public method that we call from the outside in the last line.
Let's run this through esbuild and see what we get:
A few notes about the command you are about to see:
- index.ts is the name of the file that contains the code above.
- The TypeScript target is set to esnext.
- Minify identifiers and syntax are listed explicitly because the "minify" flag would have also enabled whitespace minification which would have made the output hard to read.
npx esbuild index.ts --target=esnext --minify-identifiers --minify-syntax
The result is this:
class MyClass {
_propertyOne = 1;
_propertyTwo = 2;
printProperties() {
console.log(this._propertyOne, this._propertyTwo);
}
}
new MyClass().printProperties();
As expected nothing really changed other than the removal of the private keyword.
Now lets update our code to use private fields:
class MyClass {
#_propertyOne = 1;
#_propertyTwo = 2;
printProperties() {
console.log(this.#_propertyOne, this.#_propertyTwo);
}
}
new MyClass().printProperties();
By using the same esbuild
command as before we now get this:
class MyClass {
#r = 1;
#e = 2;
printProperties() {
console.log(this.#r, this.#e);
}
}
new MyClass().printProperties();
Private fields have been renamed to much shorter names resulting in a smaller bundle size.
What happens if you don't target the latest version of ECMAScript?
Let's run the esbuild
command again but this time setting the target to es2021
:
npx esbuild index.ts --target=es2021 --minify-identifiers --minify-syntax
The result looks quite different:
var n = (r, e, s) => {
if (!e.has(r)) throw TypeError('Cannot ' + s);
};
var t = (r, e, s) => (
n(r, e, 'read from private field'), s ? s.call(r) : e.get(r)
),
i = (r, e, s) => {
if (e.has(r))
throw TypeError('Cannot add the same private member more than once');
e instanceof WeakSet ? e.add(r) : e.set(r, s);
};
var o, p;
class MyClass {
constructor() {
i(this, o, 1);
i(this, p, 2);
}
printProperties() {
console.log(t(this, o), t(this, p));
}
}
(o = new WeakMap()), (p = new WeakMap()), new MyClass().printProperties();
50% of the resulting code consists of logic we didn't write and it does something with WeakMap
and WeakSet
?! What is going on? The "problem" that is being solved here is that private fields are supposed to be private at runtime. When we select a target that doesn't support them natively we need to emulate that or otherwise, we would have created a different behavior in our code. All of that extra code makes sure that the fields stay private. This additional code is neither great for bundle size nor runtime performance. Therefore I wouldn't recommend using private fields when your target doesn't support them. At least not if you are looking for the smallest and fastest code.
Sticking with TypeScript private and using mangle-props
After reading the last paragraph you might be thinking to yourself that you want to stick with the private
keyword from TypeScript as to not introduce runtime performance hits for older targets. But then we are back at the problem that we can't rename the properties since they aren't really private. This is where mangle-props
comes in. Tools like esbuild
and terser
can be told to rename properties under certain conditions. If we are 100% sure that our private properties are never accessed from the outside, we could allow them to be mangled.
To demonstrate how that works I purposefully put an underscore (_
) in front of the names of our private properties in the original example. Let's run esbuild
on that one again but this time it is allowed to mangle properties that start with an underscore.
npx esbuild index.ts --target=esnext --minify-identifiers --minify-syntax --mangle-props=^_
The result of which is actually the smallest version of them all:
class MyClass {
s = 1;
e = 2;
printProperties() {
console.log(this.s, this.e);
}
}
new MyClass().printProperties();
Performance
One major downside of JavaScript's private implementation appears to be much slower runtime performance (even when it isn't emulated with WeakMaps). I ran a test on jsbench.me comparing these two code snippets:
class MyClass {
#_propertyOne = 1;
#_propertyTwo = 2;
#_sum = 0;
sumProperties() {
for (let i = 0; i < 10_000; i++)
this.#_sum += this.#_propertyOne + this.#_propertyTwo;
}
}
new MyClass().sumProperties();
class MyClass {
_propertyOne = 1;
_propertyTwo = 2;
_sum = 0;
sumProperties() {
for (let i = 0; i < 10_000; i++)
this._sum += this._propertyOne + this._propertyTwo;
}
}
new MyClass().sumProperties();
The first test case contains private fields, the second one doesn't. On Chrome the first case is ~50% slower. On Firefox ~75% and on Safari ~100%!
Conclusion
If you want the smallest and fastest code then TypeScript private
and mangle-props
is your best bet. But since mangle-props
is not a safe operation, and the performance hit of private fields might not be significant in many situations, using JavaScript's new private fields could be a good alternative to decrease bundle size.
As with any kind of optimization you should always perform measurements for your exact case. JavaScript performance can be quite unpredictable. Your mileage may vary.