This is a proposal for amending the semantics of the ECMAScript 2015 class
construct, for allowing their constructors to be passed preallocated objects.
-
User-defined constructors defined through
function
may either produce fresh objects, usingnew Foo
, or initialise a preallocated object, usingFoo.call(thisValue)
. The latter form of invocation is popular when doing subclassing or mixin. -
Builtin constructors always produce fresh objects. They cannot initialise preallocated objects, and there were serious technical issues (IIRC, with builtins from the DOM) in attempting to do so. They cannot be subclassed the traditional pre-ES6 way, e.g. using
Foo.call(thisValue)
.
The challenge is to make ES6 classes (that is, constructors defined through the keyword class
) work seamlessly for both cases. Ideally, the constraints are:
- ES6 classes should be able to subclass builtins that cannot work on preallocated objects; but,
- ES6 classes should just work with preallocated objects (i.e., when invoked as
Foo.call(thisValue)
), when possible (i.e., when not subclassing builtins and other restrictions); and, - ES6 classes that cannot work on preallocated objects (e.g., those that extend builtins) should protest loudly when trying to be used that way.
Classes as specified in ECMAScript 2015 verify Constraint 1. The following proposal allows to have Constraints 2 and 3 as well.
Constructors (functions that can be invoked through a new
expression) are classified between those that can work on preallocated objects, and those that cannot. Let’s call them preallocatable and non-preallocatable respectively (bikeshed possible for better name...)
The rules for determining the... uh... preallocatability of constructors are the following:
- Builtins (
Array
,Map
, ...) are typically non-preallocatable. (However one might makeError
constructors preallocatable, because Error objects don’t really have a specified useful internal state.) - Constructors defined using the
function
construct are preallocatable. - Constructors defined using the
class
construct- are non-preallocatable if they extend a non-preallocatable constructor;
- are preallocatable otherwise.
- Constructors obtained by the
.bind()
method are non-preallocatable. This is because bound functions cannot be passed a foreignthis
value.
Now, let’s take a random constructor Foo
:
- When invoked as constructor (e.g.,
new Foo
), the semantics are already known and there is nothing to change. - When invoked as function (e.g.,
Foo.call(obj)
)- if
Foo
is preallocatable, a normal function call occur; - if
Foo
is non-preallocatable, it depends. WhenFoo
is theMap
builtin a TypeError is thrown. (If you like splitting hairs: a normal function call occur, but that invocation is specified to always throw.) Alternative behaviours are discussed in the next section.
- if
Inside a user-defined class constructor, the super()
invocation has the following semantics:
- If the
this
value is not yet initalised (or, rather, allocated), the super-constructor is invoked with the semantics of constructor and thethis
value is initalised accordingly, as currently specified. Thesuper()
expression evaluates to the newly-initialisedthis
value. - If the
this
value is already initialised (allocated):- if the super-constructor is preallocatable, it is invoked with the semantics of a function call, forwarding the
this
value. Thesuper()
expression evaluates to returned value, or to thethis
value if the returned value isundefined
. - if the super-constructor is non-preallocatable, a TypeError is thrown.
- if the super-constructor is preallocatable, it is invoked with the semantics of a function call, forwarding the
Those rules allow super()
to just work when a class constructor is invoked either with new
or through a direct call passing a preallocated object. The TypeError prevents buggy calls to non-preallocatable super-constructors.
What should be the behaviour of Foo.call(thisValue)
when Foo
is non-preallocatable? There are the following possibilities:
- Throw a TypeError. This is how
Map
is specified. - Ignore the
this
value and pretend that it was invoked as a constructor. This is whatArray
does. - Have some alternative semantics. This is how
Date
(mis)behaves.
For bound functions, Option 3 is used, basically because it is not possible to guess the programmer’s intentions. For constructors defined through the class
construct, any three possibility could be picked, but Option 3 is the one that gives the most freedom (e.g., if someone wants to mimic the Date
behaviour, don’t ask me why). However, one sould be careful to remain safe, in the sense that Constraint 3 (see Section “The Issue”) is not violated.
Concretely, let’s take Foo
a non-preallocatable user-defined class constructor, and let’s say that Foo.call(thisValue)
will do the most naive thing, that is, invoke Foo
with the semantics of function call, passing thisValue
as the this
value.
Because Foo
is non-preallocatable, it is extending by definition a non-preallocatable constructor. Then, as soon as the super-constructor is invoked using super()
, a TypeError is thrown. That means that, without an explicit action from the programmer, a TypeError will probably be thrown, like Option 1 but somewhat later.
Moreover, the programmer can easily opt-in for Option 2 or 3 with a simple test on new.target
.
class Foo { /* ... */ }
class Bar extends Foo { /* ... */ }
// the two following lines are equivalent, unless the programmer
// does strange things in the code of Foo or of Bar:
let bar = new Bar(42)
let bar = Object.create(Bar.prototype); Bar.call(bar, 42)
// Now let’s try:
class MagicArray extends Array { /* ... */ }
// the following code will throw when the MagicArray constructor
// attempts to invoke `super()`:
let a = Object.create(MagicArray.prototype); MagicArray.call(a)