Skip to content

Instantly share code, notes, and snippets.

@bathos
Last active January 4, 2022 11:10
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bathos/b134b7559161ddb80e53b3ee4cca253f to your computer and use it in GitHub Desktop.
Save bathos/b134b7559161ddb80e53b3ee4cca253f to your computer and use it in GitHub Desktop.
class-rant.md

Classes Are Making Me Sad

In the lead up to ES2015 there were a lot of voices saying introducing class syntax was a bad idea. I didn’t agree. I kind of understood what they were getting at, but as a dev working a lot in Node I saw it as a huge improvement over the awkward util.inherits dance. I didn’t really think it was a big deal that new sort of locks-in usage to a specific pattern -- I still don’t think that’s so important, actually.

But the key thing I failed to grok was that the pre-existing concept of "constructor function" in JS was what was problematic. The class syntax proposal was building on sand.

This

Because JS was my first language, and because I got to it in the ES5 era, I never really had a problem with this -- bind was always part of my lexicon, and I had no prior expectations about how this might behave. I don’t think I ever typed the expression self = this, and it wasn’t clear to me why so many people did (I didn’t know yet that the introduction of bind was relatively recent then, or about how patterns tend to just stick around).

But I heard often that this was seen as confusing, and eventually, when I began working with other devs and using ES2015 classes, I started to see why that might be so.

I was working mostly on Angular projects, and the most common situation where I saw people struggling with invocation context was when passing functions into directives. Usually this meant they were using the "=" attribute binding when they wanted "&", or were using "&" but not quite correctly; however, underneath this was a different problem. People expected methods defined in a class body to be "sticky" to the instance even if they got "pulled off".

This led folks to throw @autobind decorators all over, sometimes in a kind of voodoo way, or else set properties to arrow functions in the constructor. The latter was a bit better, but both of these shared a common problem in my eyes: people were using class syntax to define objects that weren’t, semantically, "classes" in the JS sense.

This seems to be an even more serious problem in the React world, which is full of class voodoo, largely owing to the common use of Babel transforms to enable the (unstable, low stage) instance properties proposal.

Constructors, Classes, and Factories

Before I go on, I want to discuss some terminology.

In JS, there never was and still is not such a thing as "a class" in the sort of sense that one could say there is in, say, Java. That doesn’t mean it’s wrong to use the word -- I mean, it’s a keyword in the language after all -- but it does make things harder to discuss precisely.

When we say "class", we might be referring to the sum collection of entities created by a class declaration or expression -- a constructor, a prototype, and the property descriptors associated with each. We also might use it to refer to the constructor in particular depending on context. This might also include constructors and prototypes defined using function and imperative code, < ES2015 style.

One more way we might use "class": to refer to a particular prototype ancestry, regardless of how it was achieved.

Before ES2015, constructors were based on a simple conceit: that any function was given, at the time of its definition, a property called prototype which is a new object that inherits from Object.prototype. This property exhibited special behavior when the function it was attached to was "constructed" (or instantiated, but "construct" is the language used in ES, e.g. in Proxy handlers), meaning invoked with the new operator.

Therefore one could declare a function and then begin outfitting this freshly created prototype with methods or other properties. Static methods could be provisioned the same way, but on the function itself. Or, if you needed inheritence, you would reassign MyFunction.prototype to an object that has the desired inheritence chain and work with that instead of the "free" prototype.

Although the principles were not very complex, actually using this system sometimes was. For one, it could be pretty poor for readability and organization. The code written would tend to be inexpressive, especially when you needed an ancestor other than Object.prototype. In one word, it was just awkward.

Class syntax did not change the mechanics described above, but it did do two things:

  1. It made the code to describe the same thing much more expressive and at least superficially, declarative, by providing syntactic structure, removing boilerplate, and using sensible defaults for the metadata associated with the property descriptors (e.g. enumerable: false). You now had a common syntactic form to describe the properties of the two objects and the function body itself -- very nice.
  2. It augmented it, by introducing the (unintrospectable) constraint that a constructor created in this manner could only be used with new and providing new syntax and semantics for dealing with the immediate ancestral constructor and prototype (super(), super.foo, and new.target).

Despite the difference in what it looks like to make them, we’re still talking about, by and large, the same thing. I need a word for this, so let’s refer to all functions that are meant to be used with new "constructors" from here on. These are functions that "know what to do" when new appears in front of them; they know that under the hood, a new object which inherits from whatever is at their prototype property will be created and provisioned as the calling context (this).

Now, let’s switch over to "factories". Object factories are often discussed as an alternative to the new / function.prototype constructor pattern -- usually in the context of people trying to convince you about how they’re better (which is not quite what I want to do here, though maybe a bit). I think this is another murky language problem, because constructors are just a very specific type of object factory.

I think it’s really important to try looking at it like this, whether you choose to use constructors or not. You know how there’s an iterator interface? Effectively, that’s what constructors are, too -- they implement a particular interface that works with new, just like the iterator interface works with for ... of. Where for knows what to do with Symbol.iterator, next and value, new knows what to do with (callable) and callable.prototype.

The same interface reappears with the closely-related instanceof operator, which knows what to do with it on the RHS value. So it’s quite baked-in; I don’t mean to create the impression that it’s not. However, what you get back from new Foo is not special. It will (normally) be an object which has a particular prototype chain. And any function can return an object with a particular prototype chain. Class syntax just makes things easier and more consistent.

...Sometimes

In ES2015 class syntax, there are a few things left unaddressed. Today there are proposals to amend that. The key ‘missing feature’ is that while the syntax allowed declarations of methods and accessors of the prototype and the constructor within the class body, it omitted declarative syntax for any other properties. Thus you still need to do this:

class Foo {
  /* ... */

  get isInactive() {
    return this.constructor.INACTIVE_STATES.has(this.state);
  }
}

Object.defineProperty(Foo, 'INACTIVE_STATES', {
  value: new Set([ 'expired', 'canceled', 'hidden' ]);
});

We can’t use static there to define the enumeration because the property in question is not a method (you could make it an accessor, but that really doesn’t make much sense since it’s a fixed value). If you’re familiar with the inner workings of this whole system, it’s not so bad, but it can surprise people who don’t realize ‘classes’ are no less dynamic than they ever were in JS, and it’s sort of ‘leaky’ in that the syntax is failing to shelter people from the process that is supposed to be abstracted away (for better or worse).

In a future version of ES, this will likely be addressed with the following syntax:

class Foo {
  static INACTIVE_STATES = new Set([ 'expired', 'canceled', 'hidden' ]);

  /* ... */

  get isInactive() {
    return this.constructor.INACTIVE_STATES.has(this.state);
  }
}

You might rightly wonder about this syntax a bit! To date, class body syntax has carefully maintained near symmetry with object literal syntax, which is quite clever since it helps to self-explain what is happening (both are just a literal syntax for describing the properties of objects, with the main distinction being that class bodies can describe two objects and an object literal can describe only one). Naturally you’d expect the syntax to maintain this symmetry:

class Foo {
  static INACTIVE_STATES: new Set([ 'expired', 'canceled', 'hidden' ])
}

The reason this will not be the case is that [[BINDING IDENTIFIER]] ":" has been ‘forbidden’ as a new grammatical production in any position due to TypeScript. Nearly 40% of the TC39 board represents organizations invested in this non-standard extension -- kind of a conflict of interest imo, and I can think of no better evidence of that than the above, where the simple, predictable, and internally consistent syntax is being eschewed for a new quirk.

As for non-accessor, non-method prototype properties, they’ll look like this:

class Foo {
  bar = 1;
}

This is recognizable to many folks from the React world. But I said prototype properties -- not "instance initializers". That’s right, all your code is gonna be invalid ES2018 (or whatever year). This will not do what it ‘does now’ with the experimental transform:

class Foo {
  bar = () => this.baz;
}

const foo = new Foo();

foo.baz = 1;

foo.bar(); // undefined

However, instance property initializers, despite being served perfectly fine by the constructor function (that’s what it’s for...), have not been left out. They will most likely get a new modifier keyword:

class Foo {
  own bar = () => this.baz;
}

const foo = new Foo();

foo.baz = 1;

foo.bar(); // 1

Altogether, not bad. Your React code will be easy enough to update (I bet someone will even make a tool for it) and all of these cases will be covered, even this completely pointless last one that only serves to further confuse what’s actually happening.

Hold On Asshole

I know, I know, I’m sorry. But now we’re cycling back around to this. When people write ‘classes’ like this, I die inside a little:

// Using the latest proposed syntax for clarity

class Foo {
  own bar = true;

  own baz = () => {
    /* ... assume `this` is used, etc ... */
  };

  own qux = () => {
    /* ... */
  };
}

The reason goes back to what I said about the "constructor interface" being just a specialized object factory. What is it specialized for? Managing prototypes. What are prototypes for? Well, mainly, to avoid creating the same things over and over when a single object could satisfy multiple usages. But we’re actually creating new, unique functions for every instance here.

Above what we have written is effectively a more complex, indirect version of this:

const createFoo = () => {
  const foo = {
    bar: true,
    baz: () => { /* ... assume `foo` is used ... */ },
    qux: () => { /* ... */ }
  };

  return foo;
};

Neither is terribly efficient, but the latter is far more expressive. We aren’t using a prototype or a constructor, so why use the constructor/prototype object factory pattern? Why create a new prototype that will never even be used? Those aren’t instance methods, at least not in JS terms; the distingusing characteristic of a ‘method’ in JS is that it has a variable this. Those are functions which happen to be properties of a related namespacing object.

What does React’s inability to handle methods mean, anyway? Angular solves this same problem by evaluating expressions, not passing values, but while this might reduce cognitive load in theory, it never works out that way in practice, and it’s quite a roundabout way to execute code. In both cases, the problem is that these situations are not what the constructor/prototype model is meant for. It is a flaw in React that they specifically encourage the use of the wrong model. Shiny things.

The Rift

Historically, one of the things that helped make JS vital was how peculiarly natural it felt to mix paradigms. Many languages support multiple programming paradigms, but because JS had a small toolset, a sandboxy environment, and didn’t lean too heavy in any one direction, it usually didn’t feel weird to use OO and functional techniques side by side.

ES2015 altered this without really changing the overall balance. Lexically scoped functions, rest/spread, and destructuring all improved the functional experience and class syntax improved the OO experience. But because it refined those usages, it may have made them a little less natural to sit side by side, especially when the constructor model is in play.

When people need to ‘bind methods’ to instances created with new, it means they’re using the wrong tool for the job. It is always a smell.

Shiny Things

Looking at the proposal landscape now, I think this pattern is continuing. There are a handful of proposals for new pipelining and binding operators that would assist with code written in a functional idiom and there are proposals for extending class syntax -- those mentioned above, plus a concept of private properties (of the constructor, of instances...).

These things seem more and more to exist in discrete universes. Consider the spread keyword. While it’s ‘functional’ in spirit, it has plenty of generic application regardless of idiom. Not quite so for |> pipelining, say.

That’s fine, I’m not saying this is a problem in itself, just putting it out there for context.

What I think I see is that the functional end of things is pretty healthy. Those operators, in the worst case, introduce a new concept that might take some folks a bit of getting used to, but they’re simple, whole ideas. They could have been proposed at any time; they depend on nothing else.

But the OO proposals are different. They either address shortcomings of the last round or continue building on -- and adding complexity to -- a somewhat broken model that is clearly very confusing to a large number of people. The part that worries me is that unlike this, which people might recognize that they didn’t quite understand, in this new pattern it’s really easy for people to make mistakes and poor choices which they’re not going to be aware of.

I’m not suggesting people should entirely avoid ES6 class syntax (or, more generally, the constructor pattern). While simpler factories, even when prototypes are in play, are quite viable (ES5 really solved this nicely with Object.create), often class syntax is a very expressive way to define an API. But I think it is a problem that they are being treated like ‘the way you define objects’ without much consideration. There is a kind of cargo culture programming being fostered by ES2015 class syntax because it looks so much like something it’s not.

@amanda-mitchell
Copy link

Could you provide a link to your source for the discussion regarding the own keyword? The language in the spec for class public fields (https://github.com/tc39/proposal-class-public-fields) seems to indicate that the current idiom will continue to work. Is there an alternate proposal or a public email thread that says otherwise?

@bathos
Copy link
Author

bathos commented Mar 9, 2017

My friend adam is a dickhole and posted this private gist publicly haha, I would never have published something this obnoxious/polemical on purpose, it was a rough stream of consciousness ranty draft for what I thought I might turn into a real article later.

@david-mitchell the unified syntax proposal which is collecting and refining the syntax of the various class extensions in flight can be found here. To be clear, it’s not a sure thing, either, but it’s more likely to end up looking something like this than not.

@amanda-mitchell
Copy link

Thanks for the link! I'd love to read the "real" article if you get around to finishing it.

@bathos
Copy link
Author

bathos commented Mar 10, 2017

@david-mitchell Thanks, but chances are I won’t now mainly cause I’m super embarrassed — it was glib and not a very complete thought. I did intend to get into the #privateProperties proposal more but needed to do more research on it, and I had some ideas for discussing the higher-level conceptual implications of prototypes and how they differ from classical OO classes (in a nutshell: many of the Thou Shalt Not tropes of Java are just inapplicable; prototypal systems and classical OO both provide a mechanism for modeling hierarchical categories alongside local state, but in classical OO the former is distinct and fixed, and in prototypal languages, categorical membership is itself a facet of local state, so the scope of "the kinds of categories which can be safely modeled this way" is wider, allowing mutable states to be "classes", which is an interesting tool for reducing repetitive branching and making good use of super references (the extreme example: dynamic setPrototypeOf, performance issues being clearly beside the point in most uses of constructors in JS; picture an instance method like toggleFoo() calling this to tweak all method implementations to the needs of a particular object state) — but even so, this system still carries too much of the same baggage because the relationships must always be hierarchical and are just fixed at a different level ... my ultimate conclusion I guess would be that, in the future, I am considering not only avoiding class syntax but also prototypal inheritance in general, using it as a tool in only very few cases and rarely at depth).

@lyndsysimon
Copy link

Since this is a gist and can't receive a PR, may I recommend the following change?: s/conceit/concept/

@lyndsysimon
Copy link

After having read this - twice - I feel like I've mostly digested it.

First of all, let me say that your "glib and not very complete thought" is a better read than most people's polished articles. In my opinion you're doing yourself a disservice by judging your own work so harshly.

Secondly, ES2015 classes have always left a bit of a bad taste in my mouth. I feel like I'm a fairly decent developer with a wide breadth of experience, and Javascript's prototypical inheritance model always felt weird to me. I could never quite remember how things worked after context switching from another language and as a result I found myself often referring to documentation to refresh my memory. With the introduction of the class syntax, my initial thought was basically "Great! Now Javascript will behave more like the other languages I use, and I can more closely align my front-end models with those on the server side!".

Nope. I quickly learned that although the class syntax meant my models looked like traditional classes, but it didn't change their behavior. Now instead of skimming some docs when I began building something so I feel like I know what I'm doing, I'm having to go back and deeply read docs to correct the implicit, invalid assumptions I've made about the objects I've already written.

What's worse, I strongly believe that the class syntax creates a significant barrier to new developers gaining deep knowledge of Javascript's object model. I have fond memories of the moment of epiphany when it first dawned on me how binding worked. I was working on a team of about a dozen developers at the time, and I distinctly remember looking through the codebase and realizing that var self = this was a code smell. That insight came from being able to easily see the relationship between functions and the objects to which they were bound - specifically, from seeing a more experienced developer using this in a standalone function and later explicitly binding it to objects in order to act upon them. With the class syntax, explicit bindings like that make it apparent how things relate are hidden under yet another layer of abstraction. Opportunity to gain insight into the object model itself are fewer while the impression that things work mostly like traditional OO is allowed to remain unchallenged for longer.

@bathos
Copy link
Author

bathos commented Mar 10, 2017

@lyndsysimon Conceit was the intended word there — in the sense of a precept that is maybe a little arbitrary but which the remaining logic of a system derives from. See third def: https://www.merriam-webster.com/dictionary/conceit — "concept" on the other hand does not carry any connotation about whether the concept in question is self-explanatory vs a thing that fell out of the sky.

@bathos
Copy link
Author

bathos commented Mar 10, 2017

Thanks :)

"Great! Now Javascript will behave more like the other languages I use, and I can more closely align my front-end models with those on the server side!".

I’ve seen this a lot, yeah. It’s one of the reasons that the upcoming "=" syntax for properties worries me — in some languages, class bodies have a scope (I think?) and that looks an awful lot more like creating variables in a scope than defining properties of [one of three!] objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment