Allow `extends undefined`, or another way to write `extends` without actually extending

Consider a mixin:

const withX = (base) =>
  class extends base {
    x() {}
  };

But now imagine that I want base to be optional. For example, I may want the following to work:

class Foo extends withX() {
}

Where the inheritance is Foo -> withX -> Object.

The naïve way to do this is (base = Object) => .... This doesn't work because now your mixin class also inherits static Object methods.

Another way that works is (base = class {}) => .... This provides the correct properties but introduces a new thing into the inheritance chain.

The way that works is to write it twice:

const withX = (base) =>
  base === undefined
    ? class {}
    : class extends base {};

But you see how with class bodies this becomes duplicative.

Because of the way class syntax works, it's very hard to reflectively inject any code into its structure. Therefore, the best way is to simply allow extending undefined, where class extends undefined {} is exactly equivalent to class {}.

1 Like

Can you get around that by extending an empty function?

function EmptyBase() {}

const withX = (base = EmptyBase) =>
  class extends base {
    x() {}
  };

You'll have an extra base in prototype chain, but no extra inherited static methods:

> class Au {}
undefined
> class Af extends function(){} {}
undefined
> Au.__proto__.__proto__
[Object: null prototype] {}
> Af.__proto__.__proto__
{}
> Af.__proto__.__proto__.__proto__
[Object: null prototype] {}
> Au.keys
undefined
> Af.keys
undefined
> Au.call
[Function: call]
> Af.call
[Function: call]
> 

For reference:

Yes, that's a good idea. I've added it as another workaround. Regardless, it's not the same as without extends.

extends null is not the same as extending nothing; it removes Object.prototype from the instance's prototype chain, no?

(It's broken at the moment anyway, so I'm not really sure what the intended behavior is, but at least that's what I wrote in extends - JavaScript | MDN)

Ah, I think I misunderstood what you were asking.

Having a static class that can, at runtime, be a base class or a derived class would definitely never be permissible, since it affects parsing - a base class can't use super, a derived class can.

The only semantic I want to have is the same "Object.prototype for instance, Function.prototype for static" behavior. I don't care if there's a useless super() that doesn't initialize anything, since Object() doesn't initialize anything either.

1 Like

The committee cares about that very much and would NOT accept a useless super - that would mask a bug - and that's also why we don't have extends null.

Well, if there "may or may not" be a superclass, it also makes sense that super() "may or may not do anything"?

I'm also fine with any of the solutions:

  • super() can only be called if there's a base class at runtime and calling it without a base class is a ReferenceError
  • Optional extends only works if there's no constructor (and we may relax it later)
  • Even... super?.() to "only call super if it exists"?

It's not like you can't shoot yourself in the foot today by writing a super() that actually doesn't get invoked, so we already have to defer part of the check to runtime, and I'm exactly proposing that we lift some of the syntactic rigidity in favor of more dynamic runtime behavior.

I'm just trying to convey that the current and desired invariant is that you can statically know at parse time if a class is considered "base" or "derived", which makes your entire request a nonstarter.

Does this invariant help browsers optimize, or just help users write safer code? As I said, you can write a class that's known to be "derived" but forget to call super(), so it's not totally fool-proof anyway without runtime checks. Sure you can statically prevent calling super() in a "definitely base" class, but we are not breaking any syntactic invariants by allows "maybe derived" class to contain "maybe invoked" super().

If you really want to uphold this syntactic invariant, maybe do it like class extends X if condition :man_shrugging:? Now we are in a new syntax space and all previous expectations will hold. This drastically bloats the desired solution though.

Are you saying it is the feeling of the committee that mixins cannot and should not ever be supported as a pattern?

I actually have been using the mixin pattern (with a default to extending Object) in a lot of my code and I didn't even know that I was polluting my classes with Object statics.

It is for that reason that I don't buy the argument "the committee would never do that." You're citing a cost that is abstract to the point of being purely theoretical and saying that it should stand permanently in the way a real and potentially-common use case, to the extent that it is not worth even worth thinking about how all desirable properties of the mechanism might be supported.

I think I can extend on @lightmare's workaround to solve the remaining issue with the extra empty prototype in the chain:

const getProto = Object.getPrototypeOf;

function StaticlessObject() {}
// shorten the prototype chain:
StaticlessObject.prototype = Object.prototype;

const Mixin = (Base = StaticlessObject) => {
  return class Mixin extends Base {
    constructor() {
      super() // super is always called
    }
  }
}

const Test = Mixin()
const test = new Test()

getProto(getProto(test)) === Object.prototype // proto chain is ok

Test.create() // as desired: fails because statics are not copied

(and best of all no need to copy the class body)

No, I’m saying that multiple members of the committee have repeatedly and empirically blocked changes that alter this invariant, and I’m attempting to set expectations.

There's still the extra link in the static prototype chain.

console.log(Object.getPrototypeOf(Test) === Function.prototype) // false
console.log(Object.getPrototypeOf(Object.getPrototypeOf(Test)) === Function.prototype) // true
// vs.
class DoesNotExtend {} 
console.log(Object.getPrototypeOf(DoesNotExtend) === Function.prototype) // true

It seems the language already doesn't agree with itself in a practical sense whether super is "useless" or not:

// implicitly extends object: you must not call the object constructor
class Foo {
  constructor() {
    // super();
  }
}

// explicitly extends object: you must call the object constructor
class Foo extends Object {
  constructor() {
    // Here both situations look the same at runtime.
    // Just guess at whether you must or must not call?
    super();
  }
}

To me that this leaves ample room for the argument that it is the current behavior which violates the most sacred rule: that you shouldn't ever be in the situation of knowing a constructor exists but only maybe calling it

I thought the workarounds might prove that mixins weren't completely blocked by this flaw, but @senocular proved to me that indeed mixins are impossible in Javascript.

@ljharb Do you have any ideas about how the language might use some combination of new or existing language features to serve the mixin use case?

Off-topic but the class syntax itself necessitates so much other syntax to get around its rigidity. Decorators, static init blocks, private properties, and now this. None of this would ever be a concern in function-based styles—the only nice-to-have thing in FP is pipeline, and X years later we are still in this place🙂

Bikeshed syntax proposal:

const Mixin = (Base = abstract class) => class Mixin extends Base {
};

It would have the meaning proposed here, and should be backwards compatible since abstract is a reserved word in Javascript.

Unfortunately per the entire discussion here so far there is no obvious existing type for the abstract class value, making its implementation exotic

The problem seems to be that @ljharb wants to preserve the invariant that "whether a class is a base class is compile-time verifiable". I originally thought about using void (which IMO is better than overloading abstract class which already exists in TS), but that doesn't solve the problem. If we are to have "maybe-base" classes, then we have to bake it into the extends syntax. For example, void is a contextually valid expression (think of it as the pipe topic token) that's only valid inside the extends clause, and you can write class extends Base ? Base : void, or class extends Base ?? void, and whenever we see void in the extends clause, we know that the class is "maybe-base".