Investigating the Return Behaviour of JS Constructors

Did you know that you can return a value from a constructor in JS? I didn't for the longest time. There's some non-obvious behaviour there too!

Given

class A { 
  constructed = true;
  constructor(o) {
   return o; 
  }
}

Why does the type of the return value seem to affect what comes out of the new expression:

js> new A()
({constructed:true})
js> new A(10) 
({constructed:true})
js> new A({override: 10}) 
({override:10})

Let's figure this out from the specification.

Looking at the Runtime Semantics: Evaluate New, we call Construct(constructor, argList).

So, inside of that Construct, we return constructor.[[Construct]](argumentsList, newTarget). So I guess we need to figure out what a class's internal slot [[Construct]] is set to.

Static semantics for class constructors are here. This doesn't seem to help me too much. Let's look at the runtime semantics of class definition evaluation instead.

So:

  • Step 8: "let constructor be ConstructorMethod of ClassBody".
  • Step 11: Let constructorInfo be !DefineMethod of constructor with arguments proto and constructorParent
  • Step 12: Let F be constructorInfo.[[Closure]]
  • Setp 14: Perform MakeConstructor(F, false, proto)

Inside of MakeConstructor we have Step 4: "Set F.[[Construct]] to the definition specified in 9.2.2.

9.2.2 is the [[Construct]] behaviour for ECMAScriptFunction objects. In that, Step 12 is what we were looking for:

If result.[[Type]] is return, then

a. If Type(result.[[Value]]) is Object, return NormalCompletion(result.[[Value]]).

b. If kind is base, return NormalCompletion(thisArgument).

c. If result.[[Value]] is not undefined, throw a TypeError exception.

So, if your constructor function returns an Object, that object is the result of the constructor. Otherwise, the return value is ignorerd, and this is returned.

Why is it like this?

After spending some time looking into the history, the conclusion is essentially that it’s something that’s needed as part of a transition plan from pre-classes JS.

I found this ESDiscuss thread: Should the default constructor return the return value of super? particularly enlightening, while not addressing the topic directly.

Some quotes:

Sebastian Markbåge:

Basic constructors still have the quirky behavior of ES functions that they can return any object and don't have to return the instantiated object. This can be useful if they're used as functions or should return a placeholder object, or other instance, for compatibility/legacy reasons. E.g. when you have a custom instantiation process.

class Foo { constructor() { return {}; } }

Allen Wirfs-Brock

It is difficult to design of a constructor body that behaves correctly in all five of these situations: invoked via the new operator; invoked with a super call from a subclass constructor; called directly; called via call/apply function with arbitrary things passed as the this value; and, called as a method. The ES6 spec. has to handle all for of those use cases for the legacy built-in constructors. But I don't think we want to encourage people to do so for new abstractions defined using ES6 class definitions because in most cases what they produce will be buggy,

Sebastian Markbåge:

The use case I had in mind was React components. Components in React are described as classes which makes them seem approachable to a broad user base. They cannot and should not be accessed as class instances though. The instances are immutable data structures used exclusively by the library. The base constructor could look something like this:

constructor(x) {
  return { _hiddenInstance: this, _instantiationContext: CurrentContext, _id: uid(), _someArgument: x };
}

This would generate a descriptor that can be used by the library but only used as a reference by the user. This allows users to declare classes just like they're used to and even instantiate them normally. However, they'd only be given access to the real instance at the discretion of the library.

Brendan Eich

Adding class syntax as (mostly) sugar for the prototypal pattern does not obviously mean rejecting all unusual or exceptional variants possible in the prototypal pattern. I say let super be used even in class C{}'s constructor, and let return-from-constructor work without requiring [Symbol.create]. JS is dynamic.