The role of inheritance in Javascript's future

(This isn't a specific proposal, I'm just seeking to have a general conversation about the direction we want to take Javascript, and wasn't sure the best place to post this)

In this post, I'm hoping to have an open-ended discussion about the role of inheritance in Javascript's future. There's a lot of smart people in this form with lots of experience, and I think we can get some good, meaningful ideas out of a discussion like this. I want to have this discussion, partially to help guide us in our decision making of the future of Javascript. I also realize there's going to be a lot of mixed opinions about this topic - this seems like an opportunity to hear each other's voices and learn from each other.

I'll start with a bit of background.

I know that for a good period of time, inheritance was all the rage - it got used left and right to solve all sorts of problems. Since then, there seems to be a backing-off, with a realization that inheritance also carries a number of problems, and maybe there are other tools available that could solve some of those problems in better ways.

I've seen a handful of proposals on these forms asking for features such as protected members, abstract classes, etc. These are features that have been around in many languages for a while, and are all heavily related to inheritance. I can't help but wonder if these types of features are still applicable today. I also see current proposals trying to build inheritance hierarchies on new built-ins for different reasons, and wonder if such hierarchies should really be done, or are we just trying to stay consistent with how we've always done things in Javascript. I've noticed that newer languages such as Go and Rust don't even provide inheritance, and wonder if there's something to be learned from them. I hear a lot of people on the internet shouting down inheritance, some saying it should rarely be used, others saying it should never be used.

I've been doing a lot of research into it on my own, trying to figure out the kinds of problems that inheritance is supposed to solve, and how those problems could be solved without inheritance (Some mainstream languages don't support some of the solutions out there, Javascript included). The more and more I look into it, the more and more I'm becoming convinced that, given a well-designed language, there really isn't any need for inheritance.

For example, often inheritance is used to customize the behavior of a particular class. Instead, the class can provide hooks into its behavior, which allow customization during construction. This is called the "strategy pattern", and it gives the class more power over what can and can not be customized, and generally results in more readable code on the end-user's side over an inheritance solution.
Another example: Often inheritance is used purely for the purposes of publicly exposing the fact that two different types can be used interchangeably (i.e. polymorphism). This can be done through the upcoming protocol proposal instead.

So, here are my discussion questions:

  • What are some other use cases for inheritance?
  • When should inheritance be used (if ever)?
  • When should inheritance be avoided?

Answers to these questions can help guide us in knowing whether or not it's a good idea to provide inheritance-related features, such as abstract classes. It can also help us know when it's appropriate to make an inheritance hierarchy with new Javascript built-ins. I realize this probably won't result in exact answers, as there will be a diversity of opinions here, but we can still learn from each other.

2 Likes

If we look deep into this language we can notice a lot of things that's rarely / not used at all; like with, switch, do...while, void, new.target... + now var.
I think these were some concepts that phased out overtime. Inheritance is a similar concept! FP made me realise that the major selling point of OOP wasn't classes, objects / inheritance but modularity and the dot notation (believe me people really do like dots😂, even so that this notation (OverloadedRecordDot) got added recently to Haskell!)
I'm optimistic about the future of JS. I'm seeing that it's decoupling itself from Java/C++ way of OOP into new future forward solutions. I can say this profoundly by quoting the proposals: maximally minimal mixins, records & tuples, static blocks, module blocks, defensible classes, orthogonal classes, ...
I endorse the mixin pattern which gives us best of both worlds - inheritance & composition (Reflect.mixin proposal). Generally I don't think OOP will ever vanish but just merge with FP to give us a solid programming model than the one we have today. Go & Rust are paving the way for this transition. Maybe even Object Oriented Optics - lenses & prisms and all that stuff currently seems terrifying in Haskell :joy: :joy:, or maybe not...

Sorry but I have to cross you on this one! knowingly or unknowingly we all use it in one form or other! Haskell uses it in typeclasses. JS simply won't be JS without it. Even in imperative C, there's multiple dispatch of functions, which inherently is inheritance! Agree it or not but inheritance is actually good for designing native APIs like DOM, and other browser APIs. And I think it's prime use should be restricted to such platforms. There's no simple answer for avoiding inheritance but I think most of the time composition or a preferable design pattern is at best. I really don't like abstract class man i.e in JS because it kind of seems useless / incomplete without static typing. Using inheritance model in built-ins doesn't seem very odd, as I said it's ideal for designing such APIs.

2 Likes

Nicely put. I get the same vibes too - we've got extreme OOP languages like Java, and extreme functional languages like Haskell, but over time I've been seeing the best parts of both bleed into each other. ReScript/ReasonML comes to mind, which is very functional in nature, but still provides convenient facilities to use mutable variables, etc, because sometimes it's easier to represent a solution to a problem using them.

I guess it depends on how you define inheritance too :). Go claims they don't have "inheritance", instead they have a feature they call "embedding", which looks almost exactly the same as inheritance - you're still grabbing a bunch of properties from elsewhere and mixing them in. What's the difference? The inheritance relationship isn't public knowledge, instead, it's treated as an implementation detail. If I have a Square and Circle class in Go, and I want to share common behavior between them, I can embed ShapeLogic into them both. This doesn't automatically make ShapeLogic into a new type that I can start using if I want to use any kind of shape, like you would expect from inheritance (e.g. no doing myFunction(ShapeLogic myShape) - if that kind of behavior needs to be exposed, then the creator of Shape and Circle should expose a Shape interface. Now, at any time, the developer can stop embedding ShapeLogic if they feel it would be cleaner, and it won't be a breaking change.

I think Go's embedding feature does suffer from many of the same downfalls as inheritance. But, what's genius about it is the fact that, no longer will someone be tempted to use their "embedding" feature to make a public, polymorphic hierarchy, because there's nothing public about this embedding feature. This reduces how often embedding gets used, and makes giant inheritance hierarchies a much more rare thing in Go. Now, embedding will only get used if you really need it.

Embedding is a feature that Javascript currently does not have (though, I'm pushing for an equivalent concept from the mixin proposal in this issue), because of this, it's understandable if someone uses inheritance to achieve what embedding would normally do.

(Disclamer - I've never actually used Go, this is just what I've learned from researching it. It's possible I've got some details wrong)

:man_shrugging: - they could have equivalently been designed with common interfaces instead (i.e. using protocols w/ string keys), and internally they use some sort of embedding feature for logic that needs to be shared. But, I don't see any reason why there needs to be a public inheritance hierarchy.

Granted, embedding and interfaces can be used to simulate inheritance, but having it divided into two pieces gives you the flexibility to only use embedding when it's absolutely needed.

Part of what's been causing me to think about all of this stuff is this issue. The proposal intends to introduce new classes like ReadOnlyMap, which share a lot of functions with Map, and will both conform to the same interface they're calling AbstractMap. That issue is trying to decide if they should make AbstractMap into a real abstract base class and actually have both Map and ReadOnlyMap inherit from it.

The funny thing is, right from the start, they were saying that they wouldn't actually inherit any behavior - each and every function would be overwritten to try and preserve as much backward compatibility as possible. So, what reason exists for making this inheritance relationship exist, except for enabling behaviors such as myMap instanceof AbstractMap and myReadOnlyMap instanceof AbstractMap. In other words, they're thinking about using inheritance, not to inherit, but purely to create an inheritance hierarchy - this felt wrong to me, and made me think that there must be something fundamentally wrong with how we tend to think of inheritance.

The reasoning there would be that they'd all share internal slot, and that there could, in fact, be shared methods on the base that could be .called on any instance, even if they were shadowed by methods that did the same thing (for back compat reasons).

Ah interesting. I didn't see that reasoning stated anywhere, but I probably missed it. Even still what would be the value of using .call() on a base-class method, over just calling the instance method directly?

function doSomething(someKindOfMap) {
  // Why do this?
  AbstractMap.prototype.get.call(someKindOfMap, 'myKey')
  // over this?
  someKindOfMap.get('myKey')
}

Perhaps it's because you also want the assurance that what you're receiving is actually a map? And not just some object with a .get() method? (I presume AbstractMap.prototype.get() would throw if this value was not a map).

If we're wanting to support a case like that, I think a better route to go would be to make an AbstractMap protocol, then before operating on a received map, you can assert that it really is a map, like this:

function doSomething(someKindOfMap) {
  if (!(someKindOfMap implements AbstractMap)) throw new Error('Bad type provided!')
  someKindOfMap.get('myKey')
}

The one missing piece to allow this kind of behavior would be the ability to make it so the creator of a protocol has full control over who's allowed to implement it (so new methods can be added without breaking backward compatibility) - an issue I opened up here on the protocol proposal.

Yes, that's the reason. implements, even as proposed by the protocol proposal, wouldn't be sufficient; the goal would be to guarantee i'm using an internal-slot-checking method and not "whatever someone happened to make accessible under .get at the time i check".

For protocols, imo the proposal only works if anyone can implement it at any time, so i'm not sure how that really applies.

Alright - let's take a step back.

So, the end goal is to provide a way to detect that whatever was passed in has a map's read-only methods, correct? For extra robustness, only the language itself should have the power to decide which classes implement this interface and which ones don't.

Right now, the plan of action is to put a hidden field on all of the classes that implement this interface, then create an AbstractMap class, where each of its methods will first check if the hidden field is present, and if not, an error will be thrown.

Hmm, technically inheritance isn't even needed to achieve that objective. It's a little odd to have Map not inherit from AbstractMap, but for this specific purpose, it's not actually needed...

Anyways, now let's consider that userland code will often be running into the same kinds of pickes that this AbstractMap/Map has run into, and would also want to solve the same kinds of problems. So, what would it look like if userland followed the example of their language designers?

class Square extends AbstractShape {
  getArea() { ... }
  getCircumference() { ... }
  ...
}

class Circle extends AbstractShape {
  getArea() { ... }
  getCircumference() { ... }
  ...
}

class AbstractShape {
  #isShape = true

  getArea() {
    if (!this.#isShape) throw new Error('Bad Type')
    if (this.getArea === AbstractShape.prototype.getArea) throw new Error('Failed to override method')
    return this.getArea()
  }

  getCircumference() {
    ... same idea as getArea() ...
  }
}

That works ... but it's certainly not an easy thing for a library creator to do, plus, it's a little weird for AbstractShape to exist in the first place, when its only intended purpose is to have its methods be called via AbstractShape.prototype.getArea.call(someObj, ...) - that's not very user friendly for end users to do.

Is there a better way? I believe so. I'm sure there's a handful of ways to solve this problem, but I believe this protocol issue I linked to earlier provides a pretty direct solution. Our primary objective of checking if an object supports a certain behavior sounds a lot like an interface (or protocol) to me. The one missing piece would be the ability to allow the creator of the protocol to forbid anyone else from implementing the protocol. If protocol authors had this power, then the library could be implemented like this instead (This example is using my proposed solution, which in turn relies on the semantics of the private declarations proposal):

// Only those with access to this locally-scoped private variable
// can cause their classes to implement this protocol.
private #isShape

protocol Shape {
  outer #isShape
  'getArea'
  'getCircumference'
  ...
}

class Square {
  ...
  implements protocol Shape {
    outer #isShape = true
    getArea() { ... }
    getCircumference() { ... }
    ...
  }
}

class Circle {
  ...
  implements protocol Shape {
    outer #isShape = true
    getArea() { ... }
    getCircumference() { ... }
    ...
  }
}

That's much easier for a library author to implement (they didn't have to implement a whole extra set of functions on a base class, purely for the purpose of providing users with the power to detect if a given object had certain features), and it's also much easier for the end-user, because they can use an "implements" check instead of using functions off of AbstractShape's prototype every time they want to call a function.

This kind of extra feature doesn't have to be mixed into protocols (perhaps it doesn't really belong there), it could maybe stand by itself, or be implemented some other way. But, the point is to show that a language with the right toolset shouldn't need to use inheritance for a purpose like this.

Hopefully the more concrete examples I shared above show how the protocol proposal can be expanded to help with more use cases than simply exposing a way for the general public to implement protocols ad-hoc on any object. I feel like it's pretty intuitive to use the "private declarations proposal" to achieve this functionality too. But, like I said, this kind of thing doesn't have to be mixed into the protocol proposal, it just seems like the best place for it right now.

JavaScript only has "reachable" and "nonreachable" - there's not really a way to be able to, say, install a protocol on multiple of my own objects, and then prevent people from doing the same thing on their own objects later, while also permitting me to install it on my own objects i create later.

In other words, the concepts of "friend" or "protected" are categorically inappropriate for the language, imo. The way you'd do your non-inheritance shape stuff is by using a weak collection, only available in the lexical scope of the constructors, add instances to the weak collection inside the constructors, and validate collection membership in the desired methods. Userland sugar for this is a private field (but it's awkward to do across multiple classes), spec sugar for this is an internal slot (but it's very rare for the same internal slot to be shared across different types of things).

It's not just about permitting commonMethod.call - which wouldn't be done directly but would be used by libraries to be robust - it's also about creating conceptual relationships between classes, which protocols also do - but protocols don't provide the former.

Isn't this exactly what the private-declaration proposal allows, but for classes instead of protocols? It doesn't feel like too big of a jump to add this feature to protocols too. They explicitly state on the proposal repo that it adds the ability to have "friend" and "protected" state.

Currently, it's limited by requiring you to define all of your classes in the same module that needs to access this shared state, which means for the case of Map, you would have to define Map, AbstractMap, and ReadOnlyMap in the same module. They also mentioned that they're looking for ways to fix that in a follow-on proposal, for example, through "friend modules". Thus, it should also be possible for you to create new classes after-the-fact that can install this protocol, while still preventing everyone else from installing it. I don't know what the "friend module" idea would look like, but I presume whatever module that defines the protocol could specify that "only modules from within parent directory can access my shared private state", or something like that.

The only way that’s provided is lexical scoping. A protocol that you never expose can certainly be installed on only the classes defined in the same scope, but that’s not usually what people mean when they say they want friend/protected.

Agreed.

I guess an alternative solution would be to just make the different classes implement some protocol without publicly exposing the protocol. The only issue is it would be impossible to do an "implements" check on that protocol if you don't have access to it. I guess another, simpler solution would be to provide a way to create and expose an object that can stand in the place of a protocol with "implements" checks, but don't actually carry the needed information to actually implement that protocol.

i.e. something like this:

protocol AbstractMapProtocol { ... } // This is never exposed publicly

class Map {
  ...
  implements protocol AbstractMapProtocol { ... }
}

class ReadOnlyMap {
  ...
  implements protocol AbstractMapProtocol { ... }
}

Object.assign(globalThis, {
  Map,
  ReadOnlyMap,
  AbstractMap: Protocol.createImplementsCheckerObjFrom(AbstractMapProtocol)
})

// ...

new Map() implements AbstractMapProtocol // true
new Map() implements globalThis.AbstractMap // true

// Externally, you're able to do "implements" checks against the exposed AbstractMap object,
// but the AbstractMap object isn't an actual protocol instance, so you're not able to use it to implement a protocol.

This is still the same idea at its core. We're still utilizing lexical scoping to hide the ability to implement a protocol. In my opinion, a solution like this makes more sense than the inheritance solution, but, obviously, that's an opinion people can disagree on ;).

And, in the end, if the only issue with this problem is that it's difficult to provide a way to privately share something with some modules/classes and not others, then that would circle us back to the point that, from what I currently see, in a well-designed language (of which none truly exist), there shouldn't be a need for inheritance, but due to the limitations of certain languages, sometimes inheritance is the only reasonable choice - in this case, the limitation is the inability to have proper "friends" in Javascript.

I look in horror at the growing "ReadOnly" pattern. It's in the DOM, too. C++'s const specifier solves this without needing a whole separate type to represent read-only views. Can javascript not find an equivalent way?

The least worst idea I've had is a read only proxy. So ReadOnly(x) creates a delegate for x and ensures that calls to x get an extra read-only flag. The algorithms for the method can then be adjusted to prohibit certain behaviours. In the case of a map, it might prohibit calls to set() and delete()

You could polyfill it with a proxy. But if the language adds a function that can mutate the map, then my proxy is broken. It's also not very efficient - at least it wasn't the last time I tried it with arrays. So bake it into the language and make it efficient. Add CopyOnWrite(x) and two of the three use cases the map people want are covered.

And this could provide a mechanism to make brand checks. It's not a generic proxy; it's a language mandated one that behaves in language defined ways. So you can trust it when it says "this is a read only map".

Having a read-only view of an existing data structure is just one scenario where people may want to use inheritance for this kind of purpose. Using a proxy instead of a subclass may work in this specific scenario, but I don't think it's a very general solution. For example, perhaps at some point, TC39 would want to make some base AbstractArrayLike class that both typed-arrays and normal arrays inherit from. This AbstractArrayLike class would contain any methods that arrays and typed-arrays have in common, so you could write a function that could accept either, and be able to operate on either type with safe brand-checking using things like AbstractArrayLike.prototype.at.call().

However, your suggestion in general could be a good one to bring as an issue on the github repo of that proposal, so that there can be a dedicated conversation around it to see if something like proxied or some C++-like "const" would really fit better with the objectives they're trying to achieve.

Edit: Maybe a better example would simply be an AbstractTypedArray class, as far as I know, one of those currently do not exist, but it seems like there's just as much reason for that kind of thing to exist, as there is for an AbstractMap/AbstractSet to exist.

All TypedArray variants already inherit from %TypedArray.prototype%. If I’m following what you mean.

Ah, didn't realize that, but a closer look at the docs shows that that's plainly true.

Then back to the TypedArray + Array base class as an alternative example. I'm sure there's others examples too.

1 Like

I think the prototypal inheritance boils down to syntax where you can access a shared (class) property of an object as if it was defined on the object itself.

The only meaningful use of prototypes that I can recall is where you utilize the built-in prototype chain resolver instead of writing your own implementation of the "shadowing".

Though with addition of the nullish coalescing operator it is getting harder to justify usage of prototype in the user code.

Regarding defining the prototype in object literal syntax... now I think that it doesn't worth it. The only real use case for that syntax that I considered was a more convenient syntaxys for defining an object with NO prototype.

Scenarios when I want an object derived from Object.prototype are quite exotic. Probably even more exotic than those where you need a Weak/Map or Weak/Set. The annoying bit is that this is what you get by default...

One example of the problem of js inheritance is Object.prototype.hasOwnProperty. And now Object.hasOwn() is recommended.

Another problem I think is access speed. obj.hasOwnProperty(name) is much slower than Object.hasOwn(obj, name) if obj is big enough , i.e. more than 50 properties, which is very common for objects in DOM