JS Class fields potentially harmful

Andrea Giammarchi
7 min readFeb 14, 2023

--

Photo by Amanda Schmidt on Unsplash

Update 2: I rest my case and complain as apparently stage 4 means it already landed, while it’s stage 3 when there’s a very tiny chance things could change (you have to convince a lot of people). Classes fields are stage 4 and considered done plus some of my libraries actually side-effectively benefit from the current state but, most importantly, nobody is concerned about even private fields leaking in the wild … “expected” they say, so “expect” those and public fields to backfire, unless you fully understand why that happens, and how to circumvent such backfiring, whenever is even possible, which is why this blog post exists in the first place: to inform developers that classes fields are kinda impossible to override so that as soon as one of them needs and override, and the value is used in the parent constructor, we’re doomed and out of possible solutions 🤷 … we’re better off avoiding these kind of cases 👋

Update: I have raised an official concern to TC39, hoping this issue will be fixed before becoming an official ES202X specification.

It’s worth mentioning that while I’ve focused this post around events listeners, something any FE developer would understand and reason about, this issue is a footgun at any level of the JS/TS stack, including servers.

Imagine that even #private fields leak to foreign, non class related, objects, because of the current state of affairs …

Read the TC39 post to know and realize more 👋

I was extending builtin DOM elements with nonchalance (literally), using all modern JS features, when suddenly something weird happened and it took me a moment to “a-ha!”.

This post is about an easy to overlook JS’ feature that could backfire on you too so let’s try to avoid this footgun in the future all together!

Once upon a time …

… almost every Web developer used to write boring, verbose, and repeated code like this one:

class Counter {
constructor(element) {
this.count = 0;
element.addEventListener(
'click',
this.onclick.bind(this)
// BOOORIIIIIIIIIIING
);
}
onclick() {
this.count += 1;
}
}

Thanks to arrow functions, the .bind call all over constructors kinda disappeared, in favor of this code:

class Counter {
constructor(element) {
this.count = 0;
element.addEventListener(
'click',
() => this.onclick()
// after-shaving-face meme
);
}
onclick() {
this.count += 1;
}
}

Great, some bloat is gone, less boring code, but do we really need to create an arrow function to then invoke a shared prototype method? 🤔

Well, I’ve talked about a way more elegant and memory friendly alternative to this pattern in the past, but today I’d like to focus on common developers code and an even more modern approach to “do the same” (it doesn’t! 🤫)

class Counter {
// enter class fields / properties
count = 0;
onclick = () => { this.count += 1 };
constructor(element) {
element.addEventListener('click', this.onclick);
}
}

Awesome! We now have a clear and better class declaration that will still create an arrow per each instance but the shared method is gone … everything looks perfect, except for one detail:

We implicitly wrote a final Class 😱

wait … WUT?” OK, OK … maybe not everyone is even familiar with what is a final class so let’s start from there with this great quote:

“A final class is a class that cannot be extended” — me, just now

The little gotcha in JS is that the final keyword is reserved but not allowed and/or completely meaningless, but effectively, that final Counter class can no longer be extended fulfilling our expectations and differently from the previous “good’ol” versions.

Let’s demonstrate this lil’ absurdity with this extend:

class DoubledCounter extends Counter {
// let's increase twice each click instead!
onclick = () => { this.count += 2 };
}

Crystal clear: we just override the class field self bound property that will be added as listener, and our job is done … or is it? Let’s test this:

const dc = new DoubledCounter(document.body);
document.body.dispatchEvent(new Event('click'));
console.log(dc.count);
// gotta be 2, right? WRONG: it's 1!

… what the actual f…?!

Let’s recap how class fields de-sugar behind the scene:

// de-sugared Counter
class Counter {
constructor(element) {
// no super() needed, but if it was,
// these properties would be defined after
this.count = 0;
this.onclick = () => { this.count += 1 }; ◁──────┐
element.addEventListener('click', this.onclick);◁┤
} │
} │

// de-sugared DoubledCounter │
class DoubledCounter extends Counter { │
constructor(element) { │
// `this` cannot exist unless super() is used │
super(element); ──────────────────────────────────┘
this.onclick = () => { this.count += 2 };
// 👆 see? it's inevitably assigned AFTER so
// the super() needed call attached the
// inherited field `onclick` BEFORE giving
// us a chance to override it!
}
}

Let’s focus on the fact when we write class fields we hide the “when” fields are attached, and because of that, there’s no way we can remove the listener previously attached in the Counter constructor so we don’t have workarounds at all to even solve this issue when we’re conscious it might happen: we’ve doomed any class that would like to extend Counter because we’ve been too smart in refactoring Counter to the latest/greatest JS features!

Using the prototype instead

The reason old code would work without issues is that onclick() {} was a method of the prototype that the instance inherits right at its creation time.

class Counter {
count = 0; // 👈 THIS IS OK!
constructor(element) {
element.addEventListener(
'click',
this.onclick.bind(this)
// this already instanceof DoubledCounter
// the onclick is form its prototype
);
}
onclick() { this.count += 1 }
}

class DoubledCounter extends Counter {
onclick() { this.count += 2 }
}

const dc = new DoubledCounter(document.body);
document.body.dispatchEvent(new Event('click'));
console.log(dc.count); // 2 🥳

To have it even more clear in our head, let’s de-sugar above code like it’s year 2000 and IE still exists:

function Counter(element) {
this.count = 0;
element.addEventListener(
'click',
this.onclick.bind(this)
);
}

Counter.prototype.onclick = function () {
this.count += 1;
};

function DoubledCounter(element) {
Counter.call(
this, // 👈 ALREADY A DoubledCounter!
element
);
}

DoubledCounter.prototype.onclick = function () {
this.count += 2;
};

Bear in mind that by no mean JS classes are just syntactic sugar for functions, but among all shenanigans I’ve read and ranted about around this topic, thinking that super() technically already has a this context reference is the best way (imho) to understand where the instance comes from, behind the sugar scene, and why having methods and accessors in the prototype is a life-saver approach in complex extends circumstances.

Fixing the issue via accessor

The following code won’t suffer the direct instances fields gotcha:

class Counter {
count = 0;
get onclick() { return () => { this.count += 1 } }
constructor(element) {
element.addEventListener('click', this.onclick);
}
}

class DoubledCounter extends Counter {
get onclick() { return () => { this.count += 2 } }
}

Admittedly on the surface it looks like we’re creating too many callbacks but practically we still have a single arrow function per instance because the accessor is shared through the prototype so … same-same!

Fixing the issue via pseudo-nonsense

Back to the double arrow, if we give enough time to our extends to complete its constructor, we’re also safe, but this is ugly AF to me:

class Counter {
count = 0;
onclick = () => { this.count += 1 };
constructor(element) {
element.addEventListener(
'click',
() => this.onclick()
// when clicked it'd be the right one!
);
}
}

class DoubledCounter extends Counter {
onclick = () => { this.count += 2 };
}

I mean … at that point let’s go back moving the onclick to the prototype as method and use just one arrow, at least we save heap for the rest of the program … 🤷

Fixing the issue like you’re WebReflection (uh wait …)

I said I wouldn’t have talked again about this pattern, but it solves them all:

  • no arrow function needed at all
  • everything passes through the prototype so it’s always safe and extensible
  • in very complex extends it’s always possible to remove a super listener through the this reference for any event previously attached
  • instances are never ever added twice per same listener type
class Counter {
count = 0;
constructor(element) {
element.addEventListener('click', this); // 🔥
}
handleEvent(event) {
this['@' + event.type](event);
}
['@click']() { this.count += 1 }
}

class DoubledCounter extends Counter {
['@click']() { this.count += 2 }
}

use handleEvent() folks” will be written on my tomb-stone before I’ll see this pattern used everywhere in every library out there but worth keep mentioning it …

but why should I care?

It doesn’t matter if you like classes or not, when it comes to Custom Elements classes are the only available primitive exposed to consumers, so at least you now know why this matters to you, in case you’ll ever use Custom Elements that are meant to be extended out there 😉

Conclusions

While direct properties, that don’t need overrides or are not used within the constructor, are just fine as class fields pattern, it should be clear by now that abusing class fields can lead to very undesired side-effects that are hard to reason about when the code looks so explicit, as these are defined within the class they belong to!

In fact, I wish fields would always be attached transparently right before the super() call happens instead of right after, just for consistency sake with everything else that is inherited already when an instance gets constructed, but I am not sure it’s too late to make this amend to specs, even if I believe it’s what anyone would expect with common patterns for classes, hence classes fields.

I hope this post brought some light and maybe helped you fix some bug no linter or TypeScript seems to worry about when classes extends other classes that use, within their constructor, fields defined at class declaration time and impossible to define before super() happens.

--

--

Andrea Giammarchi
Andrea Giammarchi

Written by Andrea Giammarchi

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.

Responses (4)