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:
- Initialize a public field to a constant (or non-argument-dependent expression, like
Date.now()
) - Initialize a public field to the value of an argument (or an expression depending on an argument)
- Initialize a private field to a constant (or non-argument-dependent expression)
- Initialize a private field to an argument expression
And there are three different syntaxes that can be used:
- Declare and initialize in class body
class Syntax1 { foo = 0; }
- Declare in class body, initialize in constructor
class Syntax2 { foo; constructor() { this.foo = 0; } }
- 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()
orsuper()
, 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]
- The two parameter lists must have the same number of parameters. If either list ends in a rest parameter, both must.
- 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.
- 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. - 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 (likefield = idParam++;
) will not be reflected in the constructor body (and soidParam
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?)
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()
. โฉ๏ธ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". โฉ๏ธ