language icon indicating copy to clipboard operation
language copied to clipboard

Alternate Primary Constructors proposal: Dissociate "Header" from "Constructor"

Open rrousselGit opened this issue 6 months ago • 4 comments

Edit 1: Added an example using this.field to manually initialize a specific field. See 'Exclude some fields from ...'


Current primary constructor proposals consider the "class header" (cf class Person(int age, String name)) as a constructor definition.

At the same time, proposals don't offer a mean for "class headers" to define asserts/super constructor/initializers/.... This leads to the creation of yet another kind of constructors: "in-body constructors" (cf class Person { this(int age); }).
IMO this over complexify the feature and leads to tough discussions around syntax for defining fields. Cf https://github.com/dart-lang/language/issues/4396

In this issue, I'd like to propose an alternative:

  • Implement "class header", but not consider them as constructors.
  • Add a syntax to inject all fields defined in the "class header" as parameters in a constructor:
     class Example(int a) {
      Example(...); // < '...' adds all fields from the class header as parameter in the constructor
    }
    
  • Change the default synthetic constructor that all classes have to also include the class header

This take on headers enable adding asserts or initializers or "non-field parameters" or super constructors, without any impact on class headers.

Table of content:

(links don't work because of Github, but it helps visualize the proposal! :smile:)

  • TL;DR syntax usage
    • Class with just a header
    • Class with a header and a constructor
    • Constructors with non-field parameters: Using ... to refer to the header inside constructors
    • Adding asserts, initializers and super constructors to classes with headers
    • Adding const to a header class.
  • Going further:
    • Mutable fields
    • Optional header parameters
    • What if a class has a header, but a constructor has no ...?
    • Support ; instead of {} as class body
    • Exclude some fields from ...
  • Key takeaways

TL;DR syntax usage

Class with just a header

class Person(String name, {int age}) {}

// ✅ Good usages
final john = Person('John', age: 42);

// ❌ Bad usages
final john = Person('John');
// ^ 'age' is required but missing

final john = Person('John', age: 42);
john.age = 42;
// ^ Fields defined through the header are 'final' by default

[!NOTE]
The header does not define a constructor. The Person('', age: 42) constructor wasn't defined by the header, but instead the "default synthetic constructor" that Dart adds when a class has no constructor.

class Example(int a) {}

is strictly identical to:

class Example(int a) {
  Example(...); // < Inject the class header inside the constructor
}

Class with a header and a constructor

class UserList(List<User> users) {
  const UserList.empty()
    : users = const <User>[];
}

// ✅ Good usages
const list = UserList.empty();

// ❌ Bad usages
final list = UserList([]);
// ^ No `UserList()` is present on the class.
// Since we defined one constructor by hand, then the "synthetic constructor"
// that Dart adds when no constructor is present is no-longer added.

Notice how adding UserList.empty() removed the default UserList(), even though we still specifying a "class header".

Constructors with non-field parameters: Using ... to refer to the header inside constructors

Constructors can use ... to "inject" all fields from the class header onto the constructor.

If fields are "named", then a required named parameter will be used. If fields are positional, then a positional parameter will be used.

Parameters can be defined on a constructor before or after the .... This will impact whether new positional parameters are added before or after those from the header.

class Example(int b) {
  Example(this.a, ..., this.c)
  final int a;
  final int c;
}

final example = Example(1,2,3);
print(example.a); // 1
print(example.b); // 2
print(example.c); // 3

Adding asserts, initializers and super constructors to classes with headers

Since headers are not constructors, if we want to add asserts & co, we should define a constructor.

class Example(int a) {
  // We can use asserts
  Example(...): assert(a > 0);
}

Example(1); // OK
Example(-1); // KO, not positive

The same logic applies to initializers:

class Example(int a) {
  // We can use asserts
  Example(...)
      : now = DateTime.now();

  final DateTime now;
}

Example(42);

And same goes with super constructors:

abstract class Base {
  Based.named(int value);
}

class Example(int a) extends Base {
  // We can use Inheritance and specify `super(...)`
  Example(...): super.named(a):
}

Example(42);

Adding const to a header class.

By default, writing class Example(int a) {} does not produce a const constructor. This is consistent with how class Example {} does not support const Example()

To add const to such class, simply define a const constructor alongside ...:

class Example(int a) {
  const Example(...);
}

[!NOTE]
If we want to go further, we could support

const class Example(int a) {}

If we do so, I would expect that the following should work too:

const class Example {}

const example = Example();

Going further:

Mutable fields

Mutability support in the header could be done with an extra keyword:

class Person(var String name, {var int age}) {}

final person = Person('', age: 42);
person.age++;
person.name = 'John';

Optional header parameters

By default, header parameters are required. This matches Records ; which only possess required values.

To customize this, we can either add ? before the name of the variable (not the type, the name)

class Person({int ?age}) {}
// ^ Defines a `int? age' field, where the parameter is optional

Alternatively, we can specify a default value:

class Person({int age = 0}) {}

What if a class has a header, but a constructor has no ...?

Using ... inside constructors is not mandatory. When a class specify a header, the associated field must always be initialized. But this doesn't have to be done through ...

We can use this.field, or initializers:

class Example(int a, int b) {
  Example(this.a)
    : b = 42
}

Support ; instead of {} as class body

For convenience, we could support replacing {} with ;:

class Example(int a);

// ^ This is identical to:

class Example(int a) {}

Although this is purely stylistic and not critical.

Exclude some fields from ...

Sometimes, a constructor may want to purposefully not include a parameter that is defined in its "header".

To do so, we could rely on initializers:

class Person(String name, int age) {
  Person(...)
    : age = 42;
    // ^ 'age' is manually initialized, and therefore `...` does not import the `age` parameter
}

Person('john'); // OK
Person('john', 42); // KO, only one parameter can be passed

This could work too by relying on this.field in constructors if we wished.

The following snippet defined age as positional, but converts it to a named parameter:

class Person(String name, int age) {
  Person(..., {required this.age});
}

Key takeaways

The benefit of this approach is:

  1. We don't define a new kind of constructors. There's no such thing as "in-body constructor" here. We keep using the old constructor syntax everyone is used to, just with the added ... features + headers

  2. We don't need to differentiate between "field parameter" vs "non-field parameter". The header always defines fields. And constructors always define nothing but parameters.

  3. Transitioning from a one-liner class with just a header to a complex class with asserts is simple. We go from:

class Person(String name, {int age}) {}

To:

class Person(String name, {int age}) {
  Person(...):
     assert(age >= 0);
}

The transition is simple to do.
We don't need to change how fields are defined. We don't remove the header either We just add a plain old constructor, with asserts/initializers/...

rrousselGit avatar Jun 11 '25 14:06 rrousselGit

Interesting! This would certainly fit well together with some kind of parameter/argument list abstraction.

eernstg avatar Jun 13 '25 16:06 eernstg

  1. We don't define a new kind of constructors. There's no such thing as "in-body constructor" here. We keep using the old constructor syntax everyone is used to, just with the added ... features + headers

It doesn't define a new kind of constructor, but it still defines a new kind of parameter list that can appear in the class header and a new ... syntax that can appear in a constructor parameter list. That doesn't seem like less total complexity to me.

  1. We don't need to differentiate between "field parameter" vs "non-field parameter". The header always defines fields. And constructors always define nothing but parameters.
  1. Transitioning from a one-liner class with just a header to a complex class with asserts is simple.

Sure, adding assert() is an easy transition. But those aren't very common. What if you have a class like:

class Foo(int a, int b, int c, int d, int e, int f) {
  Foo(...) : assert(a < f);
}

Later, you realize that you don't want to store c directly as a field. If I understand your proposal, since c is not the last positional parameter, you are forced to change the class to:

class Foo() {
  final int a;
  final int b;
  final String c;
  final int d;
  final int e;
  final int f;

  Foo(this.a, this.b, int c, this.d, this.e, this.f) : assert(a < f), c = c.toString();
}

That seems like a pretty rough cliff to make users climb. In contrast, with an in-body primary constructor, you start with:

class Foo {
  this(
    final int a,
    final int b,
    final int c,
    final int d,
    final int e,
    final int f,
  ) : assert(a < f);
}

And then you change it to:

class Foo {
  final String c;

  this(
    final int a,
    final int b,
    int c,
    final int d,
    final int e,
    final int f,
  ) : assert(a < f),
      c = c.toString();
}

munificent avatar Jun 13 '25 22:06 munificent

What if you have a class like:

class Foo(int a, int b, int c, int d, int e, int f) {
  Foo(...) : assert(a < f);
}

Later, you realize that you don't want to store c directly as a field. If I understand your proposal, since c is not the last positional parameter, you are forced to change the class to:

class Foo() {
  final int a;
  final int b;
  final String c;
  final int d;
  final int e;
  final int f;

  Foo(this.a, this.b, int c, this.d, this.e, this.f) : assert(a < f), c = c.toString();
}

You can do better. You'd have:

class Foo(int a, int b, String c, int d, int e, int f) {
+   Foo(this.a, this.b, int c, ...) : assert(a < f), c = c.toString();
}

This isn't too bad.

And to be fair, this issue only happens because you're using a large number positional parameters. I highly doubt that your example will be common. Having 5+ positional parameter is really bad practice I'd say. And it's even rarer to have an initializer in this scenario.

I'm not sure how you usually gather usage statistics on Github. But I'd expect your example to almost never come up.
Even when a constructor has positional parameters, it's usually just 1 or 2 params, followed by named parameters. Like Text('Hello world', key: Key(...), style: ...). And this would be easy to transition.

A more realistic example is:

class Foo({int a, int b, int c, int d, int e, int f}) {}

And then we have:

class Foo({int a, int b, String c, int d, int e, int f}) {
  Foo({..., required int c}): c = c.toString();
}

That's significantly better I'd say.

rrousselGit avatar Jun 14 '25 08:06 rrousselGit

I would like this proposal better if all parameters were also updated to final by default. It's a breaking change, but the behavior would be consistent across headers, parameters, and records.

mmcdon20 avatar Jun 14 '25 16:06 mmcdon20