Proposal: access to constructor parameters in field initializers

This concept has certainly seen seen discussion before, notably:

  • https://es.discourse.group/t/shorthand-to-initialize-private-class-property/866
  • https://es.discourse.group/t/parameter-properties-in-js/683
  • https://es.discourse.group/t/class-property-parameters/543

But given that all those discussions are over a year old, I wanted to start a new one rather than adding on. The problem, to make sure everyone is up to speed, is how to reduce boilerplate in class constructors of the form

class Cls {
    #bar;
    constructor(foo, bar) {
        this.foo = foo;
        this.#bar = bar;
        console.log("Constructed Cls:", this);
    }
}

While this proposal doesn't go as far as some people would like, I feel that it's more in-line with the existing syntax and semantics of JS, especially given the existence of private members in modern ES6 classes. It should also remove some footguns, including one I just fell victim to myself, as well as promoting good code practices.

Currently, there are (at least) four different main scenarios for class field initialization:

  1. Initialize a public field to a constant (or non-argument-dependent expression, like Date.now())
  2. Initialize a public field to the value of an argument (or an expression depending on an argument)
  3. Initialize a private field to a constant (or non-argument-dependent expression)
  4. Initialize a private field to an argument expression

And there are three different syntaxes that can be used:

  1. Declare and initialize in class body
    class Syntax1 {
        foo = 0;
    }
    
  2. Declare in class body, initialize in constructor
    class Syntax2 {
        foo;
        constructor() {
            this.foo = 0;
        }
    }
    
  3. Do not declare, initialize in constructor
    class Syntax3 {
        constructor() {
            this.foo = 0;
        }
    }
    

At present, the only syntax that can be used for all four scenarios is Syntax 2, which is also the most verbose; for scenario 4 (argument โ‡’ private field, which is quite a common use case), it's the only possible syntax. It's worth noting that this syntax is also, by a very tiny amount, the least efficient at runtime: the engine is required to populate this.foo and set it to undefined prior to the constructor code running, which means you have a double-assignment.

This is the footgun I ran into; I didn't realize that simply declaring a property in the class body forced an assignment to undefined, which means that even properties that get assigned in a base-class constructor will be reset after super(). It's also a pain for static typing, as the type-checker (or code-reviewer) has no idea what type a field will be without first going to look at the constructor function, which may be several screens down.

My proposed syntax allows all four use cases to be written using Syntax 1, and has minimal "magic":

class Cls(foo, bar) {
    foo = foo
    #bar = bar;
    constructor(foo, bar) {
        console.log("Constructed Cls:", this);
    }
}

(In my proposal, the second (foo, bar) can be omitted so that it reads constructor {...} but I didn't want to confuse the issue. See below.)

Details as follows:

  • A class name (or the class keyword, for anonymous class definitions) can be followed by a formal parameter list, defined the same as for a function definition.

  • The names bound by the parameter list are available to use in any expression that appears in the class body. (This causes no conflict with field names, which must always be prefixed with this. when they appear in expressions.)[1]

  • If the class name is followed by a formal parameter list and an explicit constructor is specified, the formal parameter list may be omitted from the constructor. If so, it is treated as though the same parameter list were specified in both places.

  • If the class name is followed by a formal parameter list and no explicit constructor is specified, the implicit constructor has the same runtime semantics as it does right now: it calls the parent constructor with the same argument list as was passed to new() or super(), then initializes the class fields.

  • If a parameter list appears both at the class header and at the constructor function, the two lists must be compatible, as they are both defining bindings for the parameters of the same function. For two parameter lists to be compatible, all of the following conditions must hold:[2]

    1. The two parameter lists must have the same number of parameters. If either list ends in a rest parameter, both must.
    2. Every pair of formal parameter declarations must be compatible. For two parameter declarations to be compatible, any one of the following conditions must hold:
      • Both declarations are SingleNameBindings that bind to the same identifier.
      • Both declarations are SNBs, and exactly one of them binds to an identifier that starts with an underscore, and that identifier is not referenced other than in the binding.
      • One declaration is an SNB, and the other declaration is a destructuring pattern.
      • Both declarations are array destructuring patterns, or both declarations are object destructuring patterns.
    3. Every identifier bound by the parameter list must have the same binding; you can't have options be the name of a parameter in the class header and then make it a member of an array destructuring in the constructor declaration, or vice versa, you can't use the same name to refer to two different parameters, and you can't have the same name appear in two different places in a destructuring pattern.
    4. Every default value initializer that appears anywhere in either list must either (a) have a syntactically-identical initializer in the same position in the other list, or (b) be omitted from the other list. These two options are semantically identical.
  • If a parameter list is provided in the class header, it will be a static semantic error to refer to any of its BoundNames in any method or accessor, unless shadowed by a different binding. This catches the programming bug of trying to refer to a field without this., if the same name is used for the parameter, and it reflects the fact that class-header parameter bindings shadow global variables in the class body (so it would be weird if the global variables became unshadowed when you go one indentation level further in).

  • All parameter bindings (both those specified in the class header and those specified in the constructor declaration) are bound at the same time: when the constructor is first invoked (prior to any call to super()). Bindings of the same name that occur in both contexts will receive the same value (on account of requirement 3 above, that if any names have a binding in both locations, they must appear in the same place in the parameter list), but any reassignment of a parameter in an initializer expression (like field = idParam++;) will not be reflected in the constructor body (and so idParam would still have its original passed-in value).

That's a lot of requirements, but it's mainly to keep the feature focused and free of surprises. It does, however, allow us to bind a parameter to both a raw argument value and to a destructured value for the first time in JS, so far as I'm aware, which lets us do things like this:

class Base({name, info}) {
    #name = name;
    get name() {return this.#name;}

    info = info;

    constructor(options) {
        console.log(`Constructing object of type ${new.target.name} `
                   +`with options of type ${options.constructor.name}:`,
                    this, options);
    }
}

// Destructure a parameter without affecting what gets passed to the base class
class BaseWithColor({color}) extends Base {
    color = color;
}

// Destructure a parameter that's also used in the base, without affecting
// what gets passed to the base constructor
class BaseWithDesc({info: {description}}) extends Base {
    // this.name has already been set by the base class
    description = `${this.name}: ${description}`;

    constructor(opts) { // opts is still the original value passed in
        super(opts);
        // this.description got set immediately after the return of super()
        console.log("Initial description is:", this.description);
    }
}

// Don't have to repeat the long descriptive name used by the constructor,
// as long as it's not referenced in the field initializers
class Shape({origin}, _sides) extends BaseWithColor {
    origin = origin;

    constructor(opts, initialSidesToGenerate) {
        super(opts);
        this.generateVertices(initialSidesToGenerate);
    }
}

Thoughts? Is this a thing that people would want enough that it'd be worthwhile to make an actual proposal of this? (And if so, any TC39 folks want to champion it if I turn this into an actual proposal?)


  1. For destructuring parameters, the runtime semantics are the same as if those patterns were stated in the constructor itself: the names are bound on constructor entry, prior to the call to super(). โ†ฉ๏ธŽ

  2. These requirements are fairly strict to avoid some refactoring-related footguns, like adding a parameter in one place and not the other, or changing the meaning of a parameter without reflecting that in the initializer. They are also strict to allow for relaxing them at some future point without having to make decisions at this juncture like "can a rest parameter in one place apply to multiple arguments declared syntactically in the other". โ†ฉ๏ธŽ

Reminds me of Kotlin:

class Person(name: String) {
    val prop = name.uppercase()
    
    init {
        println("prints $name")
    }
}

I do like the concise syntax, though i think it would be worth researching if the scope rules are intuitive. It might look like they should be accessible anywhere within the class definition:

class C(arg) {
  f = arg; // ok

  @dec(arg) // globalThis.arg
  f2;

  m() {
    return arg; // globalThis.arg
  }
}

That's the main reason that I suggest that be a strict syntax error, rather than actually resolving to globalThis.arg. If you try to do that, the compiler will quickly tell you you're wrong.

That's not quite as good as the scope rules being intuitive, though, I agree. That said, even though the production of code isn't 100% intuitive ("what can I do with this syntax?"), I think that the reading of it is fairly straightforward ("what does this strange syntax mean when I see it in the wild?"), especially seeing as how it promotes the repetition or dummying of parameter names between constructor and class header. If you understand that in JavaScript a class is its constructor, then seeing a parameter list following the identifier would make me think of function calls, and the only places I could imagine parameters being passed to a class are in a new Class() context, or in a Class() context (like how String can be called as a function). The code I'm reading, assuming it compiles, won't contain any bare calls to the class name, as that's still a TypeError. In fact, if the code I'm reading used the option to omit the parameter list after the constructor name, I'd be most likely to assume that this was an alternate syntax that had been there all along, if only I'd thought to try it.

1 Like

Oh I must have missed that part. If you can, maybe editing the post to show a code example of the errors may make it clearer?

It would be interesting to get some of the JS engine teams perspective, they may want this to be a ReferenceError, so it's checked when the code runs rather than as part of the parser, to avoid parsing over head.

For this to be a syntax error the parser would need to keep state of the references:

class C(a, b) {
  m1() {
    a; // error
  }
  m2() {
    b; // not an error
    var b;
  }
}

I don't think it would need to be that difficult if the constructor arguments were simply passed through the initializer function used to define the field.

Fair point! I, too, would love to get some input from folks who have worked more closely with the engine :smile: (I originally had a number of links to the spec with footnotes about how it could get implemented in the abstract machine, but Discourse complained that as a new user I was adding too many links. That's also why the "earlier threads" bit at the top has raw URLs instead of links.)

The reason I was thinking Syntax Error is because there's no need to add overhead to the ResolveBinding or GetValue operations, both of which are fairly performance-critical, and because it's easy to do at parse time. It'd go in the Static Semantics: Early Errors for scripts and modules, right after the private identifier check:

  • It is a Syntax Error if AllPrivateIdentifiersValid of StatementList with argument ยซ ยป is false unless the source text containing ScriptBody is eval code that is being processed by a direct eval.

The implementation would be approximately as complex as AllPrivateIdentifiersValid, too - it just takes a single walk through the AST, keeping a list of "poisoned identifiers" that you add to when entering a class member body and subtract from whenever you encounter a scope with a let or var declaration of those names.

I think having this be an early-as-possible error is incredibly important for this, because as you said, the syntax certainly appears as though you might be able to reference once of those names within a method body if you don't understand the feature, and the consequences of getting that wrong could be hard to track down.

It is a good idea to show an example of the errors this can generate, though! I'll add some to the initial post.

It seems I can't edit my post (new user permissions again, very possibly), so instead, here are examples of all the errors I can think of off the top of my head:

Apparently it gave me an error message but edited the post anyway, so look up there instead :sweat_smile:

Arrrgh I was wrong, it gave me an error message but then updated my page to make it look like I'd edited the post. If it weren't for Windows clipboard history I would have lost the entirety of the following:

class WrongConstructorSyntax(name) {
    name = name;
    // Syntax error: empty parameter list is not the same as omitted
    constructor() {
        console.log(`Created new object ${this.name}`, this);
    } 
    constructor { // OK
        console.log(`Created new object ${this.name}`, this);
    }
}
class ConstructorParametersDifferent(id, name, description) {
    id = id;
    name = name;
    description = description;

    // Syntax error: class has 3 parameters, constructor has 2
    constructor(name, description) { 
        console.log(`Created object ${name} with description ${description}`);
    }
    // Syntax error: constructor parameter 1 has two names, "id" and "name"
    constructor(name, description, id) { 
        console.log(`Created object ${name} with description ${description}`);
    }
    // Syntax error: constructor parameter 3 has two names, "description" and "label"
    constructor(id, name, label) {
        console.log(`Created object ${id} named ${name}`);
    }
    constructor(id, name, description) { } // OK
    constructor(id, name, _desc) { }       // Also OK
    // Syntax error: constructor discard parameter _desc referenced by expression
    constructor(id, name, _desc) {
        console.log(id, name, _desc);
    }
}
class ParameterStructureDifferent(id, name, arg1, arg2, options) {
    id = id;
    name = name;
    opts = {
        arg1,
        arg2,
        ...options,
    };
    // Syntax error; parameter 3 is a rest parameter in the constructor but not the class header
    constructor(id, name, ...otherArgs) {
        console.log(`Created new object with id ${id} and name ${name}`, this);
    }
    // OK, but linter might complain about unused parameters
    constructor(id, name, arg1, arg2, options) {
        console.log(`Created new object with id ${id} and name ${name}`, this);
    }
    // OK, and the linter should be happy too
    constructor(id, name, _a1, _a2, _opts) {
        console.log(`Created new object with id ${id} and name ${name}`, this);
    }
    // Also OK, fields have already been assigned
    constructor {
        console.log(`Created new object with id ${this.id} and name ${this.name}`, this);
    }
    // Best; omitted parameter list copies from the class header
    constructor {
        console.log(`Created new object with id ${id} and name ${name}`, this);
    }
}
class DestructuringDifference(name, [a1, a2, ...aRest], {tag, flags: {extra} = {}}) {
    name = name;
    firstTwoArgs = [a1, a2];
    otherArgs = aRest;
    #tag = tag;
    #extraFlagExplicitlyNegated = extra === false;

    // Syntax error: parameter 3 uses both array and object destructuring
    constructor(name, _desc, [arg1, arg2]) { }
    // Not a syntax error as written; the destructuring check is only top-level,
    // since the primary goal of the syntax rules is to avoid bugs when adding,
    // removing, or reordering arguments.
    // This will instead (probably) fail at runtime with a destructuring TypeError.
    constructor(name, _args, {flags: [extra] = []}) { }
    // Syntax error: identifier "a1" is bound to two different places in the constructor
    // parameter list: arg2[0] and arg3.additives[0]
    constructor(name, _args, {additives: [a1, a2]}) { }
}

class ParameterInitializerDifference(name = "", id = -1, {info: {handler = 007} = {}} = {}) {
    // Syntax error: constructor parameter "name" has different initializers "" and "Unknown"
    constructor(name = "Unknown", id = -1, options = {}) { }
    // Syntax error: constructor parameter "id" initializers must by syntactically identical,
    // not just semantically equivalent
    constructor(name = "", id = (-1), options = {}) { }
    // OK
    constructor(name = "", id = -1, options = {}) { }
    // Also OK, initializers do not need to be stated in both places, either is fine
    constructor(name, id, options) { }
    // Syntax error: destructuring initializer for arg3.info mismatch, {} vs {handler: 007}
    // (This could be fixed by changing the class header to match this)
    constructor(name, id, {info = {handler: 007}}) { }
}

class WrongParameterScope(name, id = -1, {strict = false}) {
    name = name;
    #id = id;
    #strict = strict;

    // Syntax error: constructor parameters cannot be used in static context
    // (this belongs in explicit constructor code)
    static lastCreatedId = id;

    // Syntax error: constructor parameters cannot be used in property accessors
    get id() { return id; }
    // OK
    get id() { return this.#id; }

    // Syntax error: constructor parameters cannot be used in non-constructor methods
    toString() { return name; }

    // Not invalid, just a bad idea. the strict = false will still take effect if an explicit {}
    // is passed to the constructor; the merged parameter initializer looks like
    // {strict = false} = {strict: true}
    constructor(name, id, options = {strict: true}) {
        // Syntax error: names declared only in the class header cannot be used in the constructor body
        console.log(name, id, strict);
    }

    // Syntax error: names present only in the constructor declaration cannot be used in a field initializer
    options = options;
}

There's another reason I like this idea, which I forgot to mention initially: the code reading order is more accurate to the execution order of the VM. When a constructor is called, the VM first binds constructor arguments to formal parameters, then initializes all fields declared in the class body, and then executes constructor code. Under this proposal (and using a standard code style of fields/properties declared above constructor), each of those three steps happens in the same order as written in the code.

class Person {
    name;                   // Step 2: initialize fields
    constructor(name) {     // Step 1: bind constructor arguments
        this.name = name;   // Step 3: execute constructor body
        console.log(`Hello world from ${name}!`);
    }
}
class Person(name) {        // Step 1: bind constructor arguments
    name = name;            // Step 2: initialize fields
    constructor {           // Step 3: execute constructor body
        console.log(`Hello world from ${name}!`);
    }
}

Well, almost. For standalone classes the execution order is identical to the code order, but in derived classes, the call to super() happens after argument binding but before constructor body execution, so the reading order still jumps around.

This could be ameliorated by allowing bare constructor {} blocks to be stated multiple times the way static {} blocks can, and putting the super() call in a single-line constructor block at the top of the class body with the rest of the constructor code elsewhere. This proposal does not put forward a multiple-constructor-block option, but that could certainly be added in a later proposal or a later revision of this one.

class Student extends Person {
    name;                     // Step 3: initialize fields
    constructor(name, id) {   // Step 1: bind constructor arguments
        super(name);          // Step 2: call base constructor
        this.id = id;         // Step 4: execute remainder of constructor body
        console.log(`Hello school from ${name}, #${id}!`);
    }
}
class Student(name, id) extends Person { // Step 1: bind constructor arguments
    id = id;                             // Step 3: initialize fields
    constructor {           
        super(name);                     // Step 2: call base constructor
        console.log(                     // Step 4: execute remainder of constructor body
            `Hello world from ${name}!`);
    }
}

/* this would be better, but it's not in scope of this proposal:
class Student(name, id) extends Person { // Step 1: bind constructor arguments
    constructor { super(name); }         // Step 2: call base constructor
    id = id;                             // Step 3: initialize fields
    constructor {                        // Step 4: execute remainder of constructor body
        console.log(`Hello world from ${name}!`);
    }
}

Alternate syntax with fewer complications and a smaller worm-can, but _still_ not in scope:
class Student(name, id) extends Person { // Step 1: bind constructor arguments
    super(name);                         // Step 2: call base constructor
    id = id;                             // Step 3: initialize fields
    constructor {                        // Step 4: execute remainder of constructor body
        console.log(`Hello world from ${name}!`);
    }
}
*/

I'll also submit as evidence the beauty of this minimally-invasive refactor from code found in the wild :smile:

1 Like

C# has object initializers that are quite convenient and, I think, solve the same problem : Object and Collection Initializers - C# Programming Guide - C# | Microsoft Learn

If you have a class with properties (fields), you can instance and set the properties (fields) without using a constructor. In ecmascript, it might look like this:

class FieldObject {
    foo;
    bar;
    constructor() { console.log(`not called when instanced with initializer`); }
    logprops() { console.info(`foo is ${foo} and bar is ${bar}` }
}

var t = new FieldObject { foo:"foobie", bar:"barbie" }; // << initializer syntax
t.logprops(); // foo is foobie and bar is barbie

Would be really fun if we could initialize with variables set to object literals or functions that return object literals:

function getMyObj () { return { foo:"foozie", bar:"barzie" }; }

var u = new FieldObject getMyObj();
u.logprops(); // foo is foozie and bar is barzie

Then of course, why not make it async too:

function async getMyRemoteObj () { return { foo:"foolong", bar:"barlong" }; }

var v = await new FieldObject getMyRemoteObj();
u.logprops(); // foo is foolong and bar is barlong

Building on this, as in C#, using an initializer does not preclude the calling of the constructor:

class ConstAndInit {
    foo;
    bar;
    name;
    constructor ( name ) { console.log(`name is ${name}`); }
    logprops() { console.log(`foo is ${foo} and bar is ${bar}`); }
}

var v = await new ConstAndInit("zoolong") getMyRemoteObj();
// name is zoolong
v.logprops(); // foo is foolong and bar is barlong

To clarify, I was referring to the initializers in the ECMAScript spec used to initialize class fields during construction ([[Initializer]] in ClassFieldDefinition). These are functions that wrap the initializer expression in the source and get called when the field is defined. They're even (currently) observable in error stacks in Chrome.

new class Example { field = doesNotExist }
// Uncaught ReferenceError: doesNotExist is not defined
//    at <instance_members_initializer>  <--- here
//    at new Example

Presumably, arguments from the constructor could easily be passed down through to these functions (ClassDefinitionEvaluation > InitializeInstanceElements > DefineField > [[Initializer]]) without any additional complicated scoping additions.

clarification noted :) I was just inspired by your use of the word "initializer" which sparked the idea

1 Like

They are quite lovely, I agree! However, they don't solve the same problem: for one, C# object initializers run after the constructor, while true instance field initializers (in both C# and ES) run before the constructor. Second, and more relevant here, is that they are a form of caller-directed field assignment, whereas this proposal is about self-directed field assignment. Not saying it wouldn't be nice to have a little syntactic sugar for the "construct and set properties" operation, but that's out of scope for this proposal.

Also worth noting, you can currently write your first three examples as one-liners in JS using the Object.assign method:

//var t = new FieldObject { foo:"foobie", bar:"barbie" };
//var u = new FieldObject getMyObj();
//var v = await new FieldObject getMyRemoteObj();

let t = Object.assign(new FieldObject(), { foo:"foobie", bar:"barbie" });
let u = Object.assign(new FieldObject(), getMyObj());
let v = Object.assign(new FieldObject(), await getMyRemoteObj());

They may not be as pretty, but they have the same semantics as their C# equivalents: call the constructor with no parameters, then set a number of properties on the resulting object, respecting setters, visibility, and all the standard property-setting semantics.

1 Like

To be more specific, JS fields are initialized before the constructor in a base class, and as the last part of the super call in a derived class.

That object initializer approach wouldn't be viable since the constructor must be evaluated in all cases.

1 Like

My earlier post was about the scope tracking for early syntax errors of the places where it would be invalid to reference them.

The C# initialisers would also be limited to public fields so wouldn't address the issue of private fields being particularly repetitive to initialize from constructor arguments.

C# object initializers run after the constructor

I did actually find that quite annoying with C# object initializers and, imo, reduces their utility - not just because they call the default constructor, but they do it before field initialization. I didn't specify in my note, but having them call constructors after would be an important distinction along with even calling them at all unless specified. Note the differences with:

var v = new FieldObject("...") {...} // calls constructor *after* field init
var w= new FieldObject {...} // does not call declared constructor 

You're right that my suggestion doesn't address self-directed field assignment

I think you're wrong about how much early scope-tracking would cost (on account of the fact that #private identifiers also need to track scope, and they throw early syntax errors), but I'm neither an implementer nor someone who has worked closely enough with engine code to be familiar with how engine parsers actually function in the wild. Do you know anyone we could ask for an opinion on that front?

That would be a major behavior change, not just for JS but for any object-oriented language, and one with unfortunate but unavoidable security implications. Object-oriented programmers routinely make the assumption that no outside code can operate on an object prior to the constructor returning (or without the constructor being called in the first place), and violating that contract would open security holes a kilometer wide across the entire Web.

This proposal is about self-assignment of field values based on parameters passed in the constructor. It is not and will never be about assignment of field values from outside the class. If you need to do that, I recommend one of the following patterns:

class Foo { }
let foo = Object.assign(new Foo(), {name: "foo"});

class Bar {
    constructor(options) {
        Object.assign(this, options);
    }
}
let bar = new Bar({name: "bar"});

I'd be happy to talk more about OOP constructor timing rationale in DM or in a new topic post, but I'd like to keep this one focused on the proposal, please.

1 Like

I was actually referring to declared constructors as noted in my example. In ecmascript it is not mandatory to include a constructor in a class. But the subject is moot anyway as you have clearly stated this proposal is about self-assignment of field values so let's move on from this :)

1 Like