language icon indicating copy to clipboard operation
language copied to clipboard

What syntax should primary constructors use for non-field parameters?

Open munificent opened this issue 5 months ago • 43 comments
trafficstars

The primary constructor proposal incorporates much of the declaring constructors proposal.

Once you move a primary constructor out of the header and into the class body, it's natural to allow an initializer list and constructor body. Once you have those, you have a context where now you could use constructor parameters that don't initialize fields.

(With an in-header primary constructor, non-field parameters are basically useless since there is no place where you can refer to them, except maybe asserts. However, if we were to put non-field in-header primary constructor parameters in scope in the class body like Kotlin does, or allow an explicit superclass call, then non-field parameters would be useful in in-header primary constructors too.)

Because non-field parameters are useful, the declaring constructors proposal has a syntax to distinguish field parameters and non-field parameters. The primary constructors proposal does too, but it proposes a different syntax. This issue is to sort out what syntax we ultimately want.

Here's what the two proposals say:

Declaring constructors

By default, a parameter is not a field parameter. The existing normal parameter syntax does not declare a field. A user opts in to declaring a field by placing either var or final before the parameter name. Example:

class C {
  this(
    int x,       // Non-field.
    final int y, // Final field.
    var int z,   // Mutable field.
  ) {
    print(x);
  }
}

There is no way to write a non-field parameter that can't be assigned in the constructor body.

Primary constructors

By default a parameter is a field parameter. In the context of an in-header or in-body primary constructor, the existing normal parameter syntax is reinterpreted to declare a field. In an in-body primary constructor, if you don't want a parameter to declare a field, you opt out of that by adding a novar modifier. Example:

class C {
  this(
    novar int x, // Non-field.
    final int y, // Final field.
    int z,       // Mutable field.
  ) {
    print(x);
  }
}

In addition, within an enum declaration, all parameters are implicitly treated as final (because the corresponding declared fields must be final):

// Declaring constructors:
enum E {
  const this(final int x);
}

// Primary constructors:
enum E {
  const this(int x);
}

Comparison

If you expand out all of the different contexts and kinds of parameters you might want to declare, here's what they look like:

                              Declaring      Primary
                              -------------- -----------------
Mutable non-field in class    int x          novar int x
Final non-field in class      unsupported    novar final int x
Mutable field in class        var int x      int x
Final field in class          final int x    final int x

Mutable non-field in enum     int x          unsupported
Final non-field in enum       unsupported    novar final int x
Final field in enum           final int x    int x

(There is no Mutable field in enum row because enums can't have mutable fields.)

I look at the two proposals as making a different trade-off between brevity and contextuality. With declaring constructors, if we also take the suggestion to disallow final on parameters outside of declaring constructors, the parameter syntax is context-free:

What you write     What it means
------------------ -----------------------------
int x              A mutable non-field parameter
var int x          A mutable field parameter
final int x        A final field parameter

You don't have to know where you are to know what a parameter means. Moving code around (like turning a non-primary constructor into a primary constructor) doesn't change its meaning. Context only determines whether a given syntax is allowed at all. You can't use var int x or final int x except inside a declaring constructor.

With primary constructors:

What you write     Where you write it    What it means
------------------ --------------------- -------------
final int x        Primary constructor   final field parameter
                   Other fn or ctor      final non-field parameter
int x              Primary constructor   mutable field parameter
                   Other fn or ctor      mutable non-field parameter
novar final int x  Primary constructor   mutable non-field parameter
novar int x        Primary constructor   mutable field parameter

The same syntax can mean different things depending on where it appears. In return for that complexity, the syntax is shorter in two cases:

  • Declaring a mutable field parameter in a class is just int x versus var int x.
  • Declaring a final field parameter in an enum is just int x versus final int x.

Note that both proposals use the same syntax for declaring a final field parameter in a class (final int x), which is the common case from what I can tell by scraping a corpus.

Language principle

Does the language have any existing principles that might guide us one way or the other? In particular, do we generally have a principle that it's better for syntax to be context-free, or that it's better to take advantage of the surrounding context to not require users to state things that must be true?

Some observations:

  • In an enhanced enum, all constructors must be constant, but we still require you to write the const.

  • If a class has a const constructor, then all instance fields must be final, but we still require you to put final on them.

  • A named parameter in a function declaration could be inferred to be required if its type is non-nullable, but we don't rely on that context because it wouldn't work in other places in the language (function types) and aim for consistency across those contexts.

  • Downwards inference makes the semantics of some expressions depend on context. Mostly this is just filling in type arguments but it can make a bigger difference in some places. {} can be a set or map literal depending on the context. Int-to-double.

  • We explicitly added a notion of "const context" to the language to avoid repeated const modifiers in nested data structures and constructor calls.

  • In an extension type, the representation field must be final but we infer that from the context and don't require or let you write final.

  • In a pattern variable declaration, an identifier is treated as a variable declaration. In a switch case, it's treated as a constant pattern.

  • The forthcoming dot shorthand is 100% about using context for brevity.

Given all that, I think the language seems to be increasingly comfortable with relying on context to know what a piece of code means. However, it's not entirely consistent and it sort of shows an evolution over time where older language features are more explicit and newer ones are more contextual.

Opinion

I'm obviously biased, but I do still prefer the approach that declaring constructors approaches. I know I'm personally responsible for some of the places in the language that are most contextual (set/map literals and patterns). But I honestly never felt great out either of those. It just seemed like the least bad option to get new syntax into an existing language.

I would prefer if the language was less contextual and more compositional. I care a lot about brevity, but I worry if we aren't careful we will lose clarity in the process. Local reasoning is still important. It may be more important in LLM-land.

There are two separate design choices (which we can split into separate issues if needed):

  • Should we infer final for primary constructor field parameters in enums? I think the answer to this should be "no". We already require the user to write const in enum constructors, and we require the user to write final for explicitly declared enum fields. I don't recall much user complaint about them. I think having a little unique syntactic sugar just for primary constructor fields here doesn't pay its way.

    Enhanced enums are pretty rare, so the benefit of the sugar doesn't come into play very often. At the same time, by being rare, users will be less familiar when they encounter enums with constructors. Given that, making them consistent with the rest of the language will help users correctly infer how they behave.

    Users have asked for mutable enums (they think they are something like sum types), and some would likely be very confused if an enum let you write int x; but then you can't assign to it.

    If we really do want to infer final for enum field parameters, then I think we should also infer it for explicitly declared enum fields. And I guess we should probably infer const on the constructor too? But I would prefer to not do any of these.

  • Should primary constructor parameters default to fields or not? This choice only matters for non-field parameters and mutable field parameters. Final field parameters, which are the common case for primary constructors are the same syntax in both proposals.

    Some arguments in favor of the declaring constructor approach:

    • It makes parameter syntax contextual and likely confusing. In a primary constructor, int x declares a field. In a non-primary constructor or any other function, int x does not declare a field. In a non-primary constructor, you write int x to declare a normal parameter. In a primary constructor, you have to write novar int x.

    • It makes mutable field parameters maximally terse, which sounds like a good thing. But it perversely encourages users to have mutable instance state when we recommend that they prefer final.

      With declaring constructors, the difference is still there because final is two characters longer than var, but that's a smaller difference than long modifier versus no modifier at all. I don't want users making instance fields mutable just because it's one less keyword to write.

    • novar is unfamiliar new syntax. (nofield would probably be better because I suspect users tend to think of parameters as "variables" more than they do instance variables.) They'll have to learn what it means. If they don't realize it's an option, they may not realize they can have non-field parameters at all.

    • It's what Kotlin does. There, primary constructor parameters are not fields by default and opt into it by being marked var or val. Users coming from Kotlin will find this approach more familiar.

    I believe the main argument in favor of the primary constructor syntax is that it is maximally terse for mutable instance fields. That's real, but to me not a very large win.

That's where I'm at now, but I could be convinced to take the primary constructor approach or some other approach. Overall, I really want to ship this feature.

munificent avatar Jun 05 '25 22:06 munificent

cc @dart-lang/language-team to get some eyeballs on this. :)

munificent avatar Jun 05 '25 22:06 munificent

Without having read everything, I'm not a fan of novar. It's negative, which is not good naming.

I'm starting to think that if we mark the primary constructor as such outside of the parameter list, then we should let every initializing formal, this.x, parameter introduce a field.

So:

class C {
  this(
    int x, // Mutable local variable.
    final int y, // Final local variable.
    int this.z, // Mutable field.
    final int this.w, // Final field.
  ) {
    print(++x);
    print(y);
  }
}

would have two normal parameters, one mutable and one final, and two field parameters, one mutable and one final. Field parameters can be implicitly final for const constructors.

Basially: You can have at most one primary constructor, which must be designated as such in some way. Inside a primary constructor, it's not an error to have a this.x without a declaration of an instance variable x, one will be created from the parameter. You can have a this.x for a declared variable. If the type is the same, a lint might suggest to use a field parameter instead.

That makes stepwise migration possible. You can take a normal initializing constructor and mark it as primary. Nothing changes, and the program is still valid. Then you can add the implicit parameter types to initializing formals, and nothing changes, other than the analyzer telling you the type is redundant. Then you remove the variable declarations, and the analyzer warning goes away. (Removing it might be a quick-fix.) Done.

Or we can say that you cannot declare a field and initialize it with an initializing formal of a primary constructor too. That avoids accidentally having the same name in two places and not noticing. (But we'll likely warn about type differences, or about having the same type in both places, so it can only be an accident if the initializing formal has a type that's a subtype of the declared variable, which is also precisely the use-case for allowing an initializing formal for an existing variable in a primary constructor.)

An in-header constructor could only be allowed to have field and super parameters, in which case you can omit the this. I'm not sure I need that.

class const Point(int this.x, int this.y);

is short enough for me, and makes it stand out enough that I won't miss the class and think it's a function.

(I wrote this up, just to have it somewhere.)

lrhn avatar Jun 06 '25 06:06 lrhn

Python has the nonlocal keyword, perhaps novar could be called local.

Wdestroier avatar Jun 06 '25 07:06 Wdestroier

I'm starting to think that if we mark the primary constructor as such outside of the parameter list, then we should let every initializing formal, this.x, parameter introduce a field.

I know I've toyed with that idea before and I'm pretty sure we've discussed it. Reusing initializing formal syntax makes a lot of sense because it already does half of what field parameters do: initialize the field. Making it declare the field is a reasonable extension.

However:

  • I'm not crazy about the idea that an initializing formal initializes an explicitly declared field if there is one, or implicitly declares one if there isn't. I think the language is clearer if a given construct always behaves the same.
  • It's pretty verbose, especially in the common case of a final field: final int x versus final int this.x.
  • To me, the syntax doesn't read as much like a declaration as final int x and var int x do.

munificent avatar Jun 06 '25 14:06 munificent

I honestly like @lrhn suggestion. The slightly extra verbosity may be a downside, but I think it is clearer and more symmetrical than using novar (or resignifying the presence/absence of final/var).

mateusfccp avatar Jun 06 '25 15:06 mateusfccp

The slightly extra verbosity may be a downside, but I think it is clearer and more symmetrical than using novar (or resignifying the presence/absence of final/var).

With @lrhn's suggestion, there are three options on the table now, not just two:

                     Declaring      Primary            Initializing
                     -------------- ------------------ ----------------
Mutable non-field    int x          novar int x        int x
Final non-field      unsupported    novar final int x  final int x
Mutable field        var int x      int x              int this.x
Final field          final int x    final int x        final int this.x

Using the existing this. syntax only spares you from having a modifier if the field is mutable, which is the minority of fields the last time I gathered data. If you want a final field, you have to do both final and this..

The declaring constructors approach is symmetric in that there's always a keyword to introduce a field, either var or final, and there's only ever one keyword.

munificent avatar Jun 06 '25 21:06 munificent

Of those three, I prefer the initializing syntax for consistency, but I can see that the most common case is the most verbose.

After that, I prefer the declaring syntax.

The novar adds more syntax, and it's new (and ugly) syntax, for the non-final non-field parameter, just to also be able to have a final non-field parameter ... which nobody uses.

What if we introduce val as a way to declare variables with no setter. We can keep final to mean "set once". Then we use val for field parameters and final for non-field parameters. (Ok, that'll be very confusing.)

Or we take the initializing syntax and allow you to omit the this, so it's just int .x, like

class Point(int .x, int .y);

lrhn avatar Jun 07 '25 07:06 lrhn

Hot take: Do we need the ability to define non-field parameters inside primary constructors?
Primary constructors aren't supposed to replace older constructors. I'm fine with some limitations if the feature can become simpler as a side-effect.

IMO the key goal of primary constructors is to be a syntax sugar for the 95% case.
I'd say a target would be to have primary constructors match Records, but with the ability to define methods/constructors.In short, this:

class Person({int age, String name}) {
  factory Person.fromJson(Map json) => ...;

  String get displayString => '$name ($age)'/

  Map toJson() => ...;
}

Would be fairly close to this:

typedef Person = ({int age, String name});
extension on Person {
  String get displayString => '$name ($age)';

  Map toJson() => ...;
}

static extension on Person {
  factory Person.from(Map json) => ...
}

In particular, we have:

  • No this.field to "opt-in" to field definition. Field definition is the default
  • No final keyword. Immutability by default (and maybe var int age if we want mutability support)
  • No required keyword. Parameters are required naturally required (and maybe {int ?age} or {int age = 0} for optional params)

Those behaviours match Records, and are better aligned with various old Dart discussions such as:

  • https://github.com/dart-lang/language/issues/136
  • https://github.com/dart-lang/language/issues/878
  • etc...

rrousselGit avatar Jun 07 '25 07:06 rrousselGit

@rrousselGit IMO this could work as long as we have the private fields support.

mateusfccp avatar Jun 07 '25 16:06 mateusfccp

I like the "Declaring" syntax, it is more readable than the "initializing" syntax because the intent is mandatory / explicit with "var" or "final" and consistent, and the novar is the least readable in my opinion for the uninitiated.

cedvdb avatar Jun 07 '25 23:06 cedvdb

Personally, I prefer the initializing syntax @lrhn proposed at least for in-body constructors. To me it seems the most consistent with how constructors already work. Note that in the declaring / primary style you still sometimes use "this" to declare a field initialization in additional constructors, and in the initializing style "this" is used to initialize a field in both types of constructors.

// current
class Rectangle {
  final int height;
  final int width;

  const Rectangle({required this.height, required this.width});
  const Rectangle.square(this.width) : height = width;
}

// delcaring / primary
class Rectangle {
  const this({required final int height, required final int width});
  const Rectangle.square(this.width) : height = width; 
}

// initializing
class Rectangle {
  const this({required int this.height, required int this.width});
  const Rectangle.square(this.width) : height = width;
}

That said, I don't like it as much for in-header primary constructors:

class const Rectangle({required int this.height, required int this.width}) {
  const Rectangle.square(this.width) : height = width;
}

I actually prefer @rrousselGit's record-style suggestion, but only for the in-header variation of primary constructors.

class const Rectangle({int height, int width}) {
  const Rectangle.square(this.width) : height = width;
}

So altogether I like record-style for in-header, and initializing for in-body.

mmcdon20 avatar Jun 08 '25 04:06 mmcdon20

@mmcdon20 I think your initializing block should be

class Rectangle {
  // intitializing
  const this({required final int this.height, required final int this.width});
  // declaring
  const this({required final int height, required final int width});

  // note that with mutable, intitializing is arguably unconsistent
  // intitializing
  this({required final int this.height, required int this.width});
  // declaring
  this({required final int height, required var int width});


  // then other constructors (both - not a declaration, decl was done above)
  this.named({ required this.height }) : width = 3;

}

this. seems unnecessary and clutters. Having to have either final or var as the first keyword makes a lot of sense to me and is consistent with declaring a variable. It just hits the right buttons in my brain personally.

Regarding the staying consistent with existing this.x syntax, to me having both clearly different is a plus, one is a declaration (final int x), the other is something else this.x

cedvdb avatar Jun 08 '25 10:06 cedvdb

@mmcdon20 I think your initializing block should be

class Rectangle {
  // intitializing
  const this({required final int this.height, required final int this.width});
  // declaring
  const this({required final int height, required final int width});

  // note that with mutable, intitializing is arguably unconsistent
  // intitializing
  this({required final int this.height, required int this.width});
  // declaring
  this({required final int height, required var int width});


  // then other constructors (both - not a declaration, decl was done above)
  this.named({ required this.height }) : width = 3;

}

@cedvdb if you read @lrhn's writeup for initializing style you will see that for const constructors the final is implied and can be omitted.

this. seems unnecessary and clutters. Having to have either final or var as the first keyword makes a lot of sense to me and is consistent with declaring a variable. It just hits the right buttons in my brain personally.

Regarding the staying consistent with existing this.x syntax, to me having both clearly different is a plus, one is a declaration (final int x), the other is something else this.x

To me this. means constructor field initialization. In the declaring style, which arguments create a field, and which do not, is more subtle. I think people might see this({required final int height, required int width}) and assume a field is created for both height and width but it is not since I omitted the var for width.

mmcdon20 avatar Jun 08 '25 11:06 mmcdon20

Would it be possible to use the object pattern syntax for field-declaring constructors? Non-field parameters can stay the same and no new keyword is needed. :) You simply prefix parameters with colons to make them fields, and I think it looks really nice! It's basically similar to object destructuring, and dart already has a decent syntax for referring to fields (or getters) in this position. And since only one constructor can have colons in the signature, it's easy to see which one is the primary constructor without forcing anyone to use the this shorthand, which is then a separate syntax sugar for any constructor. Some people may still prefer dart's current way of making the constructor definition look more similar to the way it looks at the call site (but now also very similar to object patterns :).

class User {
  final int age;

  final User(:String name, num age):
    age = age.floor();

  // "this" is free to be used anywhere the user chooses, or not at all
  this.fromJson(Map<String, Object?> json):
    this(json['name'] as String, json['age'] as num);
}

One can easily make most fields mutable, or not, if they so choose. Also, some may like to write final, even if it's the default, since it is descriptive of the object being created.

class MostlyImmutable {
  final this(
    :int pub1,
    :int pub2,
    :int pub3,
    :int pub4,
    :var int pub5, // opt in to setter
  );
}

class MostlyMutable {
  var this(
    :int pub1,
    :int pub2,
    :int pub3,
    :int pub4,
    :final int pub5, // opt out of setter
    num pub6, // looks like a normal parameter because it is
  ):
    pub6 = pub6.floor();

  final int pub6;
}

Sometimes "var" objects may be useful, so they should also be easy:

class var State(:int a, :int b, :int c, :int d) {
  State snapshot() => State(a, b, c, d);
}

So, basically the boilerplate gets moved out of the parameter list, and if you see something that's not a parameter type or name, then it's signal rather than line noise. The contents of the parameter list describe the data, and the meta-info is moved to the margins.

Also, the colons don't clash with named parameters because we're in receiving position:

/// "final" is the default :)
class const User({ :required String name, :required int age, });
class var Rgba({ :required int red, :required int green, :required int blue, :required double alpha, });

Extension types should be allowed to have colons for consistency, or in case people want to emphasize that a getter exists???

extension type Id(:String string);
enum E12(:int n)  { one(1), two(2), }

To me, this is an improvement and feels very much like Dart.

kmccur avatar Jun 09 '25 10:06 kmccur

What if we add a rule that an initializing formal parameter (this.x) will create a corresponding field if one does not already exist?

The following is already valid code in current dart.

class Coordinate {
  final double latitude;
  final double longitude;
  
  const Coordinate({
    required double this.latitude,
    required double this.longitude,
  });
}

We would be able to remove the fields, since they are now redundant.

class Coordinate {
  const Coordinate({ // still a regular constructor
    required double this.latitude,
    required double this.longitude,
  });
}

And you can move to the header as follows:

class const Coordinate({
  required double this.latitude,
  required double this.longitude,
}) {}

Note that this approach is different from the initializing style in that there is no longer a "primary" constructor just an upgrade to regular constructors.

One thing you would be able to do with this style is declare some fields explicitly and others implicitly. In some situations you might want a field that is a supertype of the parameter type, and you can achieve this with an explicit field declaration.  

class User {
  final Object id; // explicit id field is a supertype of int and String

  User.fromDatabase({
    required String this.name,
    required String this.occupation,
    required int this.age,
    required int this.id, // declared as a subtype of explicit field
  });

  User.fromApi({
    required this.name, // type can be omitted if declared in earlier constructor
    required this.occupation,
    required this.age,
    required String this.id, // declared as a subtype of explicit field
  });
}

mmcdon20 avatar Jun 09 '25 16:06 mmcdon20

I just feel like:

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

Is far from what some people want, which is:

class Person({int age});

There are too many keywords in the current proposal IMO. It's quite the mouthful.

rrousselGit avatar Jun 09 '25 17:06 rrousselGit

The proposal is to add both, right? The primary constructor and a shorthand if the constructor needs to have a body, for example. I agree class Person({required int age, required String name}) {} / class Person({int age, String name}) {} are the shortest and most natural. The class Person(int this.age, String this.name) {} syntax defeats the main purpose of the feature.

Wdestroier avatar Jun 09 '25 17:06 Wdestroier

Hot take: Do we need the ability to define non-field parameters inside primary constructors?

Do we need it? No. We don't need any of this. We don't even need the existing initializing formal (this.) of super parameters (super.) syntaxes. It's all syntactic sugar and doesn't increase the capabilities of the language one bit.

Primary constructors aren't supposed to replace older constructors. I'm fine with some limitations if the feature can become simpler as a side-effect.

Why wouldn't you want to use an in-body constructor to replace all of your older constructors? Unless the class name is shorter than four letters, it's strictly more succinct and expressive than the current syntax. The only thing that old style constructors have is the ability to have more than one of them.

I think we should make in-body primary constructors as expressive as we can because they're just straight up better. If we make them not support non-field parameters then as soon as a user needs to do a tiny amount of computation from a parameter to a field, then they'd have to turn the entire constructor into an old style one. Consider a class like:

class SomeVeryLongClassName({
  required final int oneField,
  required final String anotherField,
  required final double theThirdField,
  required final bool soManyFields,
  required final List<String> okLastOne,
});

Let's say you realize you need to do a little computation when initializing one field. If in-body primary constructors allow non-field parameters, you can change it to:

class SomeVeryLongClassName {
  final int oneField;

  this({
    required int oneField,
    required final String anotherField,
    required final double theThirdField,
    required final bool soManyFields,
    required final List<String> okLastOne,
  }): oneField = oneField.abs();
}

If we don't support non-field parameters, you have to fully expand the constructor and fields out to:

class SomeVeryLongClassName {
  final int oneField;
  final String anotherField;
  final double theThirdField;
  final bool soManyFields;
  final List<String> okLastOne;

  SomeVeryLongClassName({
    required int oneField,
    required this.anotherField,
    required this.theThirdField,
    required this.soManyFields,
    required this.okLastOne,
  }) oneField = oneField.abs();
}

munificent avatar Jun 09 '25 21:06 munificent

@munificent I was off-topic, sorry. I was talking about the "header syntax". You wrote:

class SomeVeryLongClassName({
  required final int oneField,
  required final String anotherField,
  required final double theThirdField,
  required final bool soManyFields,
  required final List<String> okLastOne,
});

I'm arguing that this is already more than what many people want. A regular request is to remove required and default variables as final, like with Records.

So we have:

class SomeVeryLongClassName({
  int oneField,
  String anotherField,
  double theThirdField,
  bool soManyFields,
  List<String> okLastOne,
});

But this is likely a separate discussion on its own.

rrousselGit avatar Jun 09 '25 21:06 rrousselGit

That's a very nice analysis, @munificent!

I also think @lrhn's idea to reuse initializing formals to designate declaring parameters is very interesting.

Language principle

@munificent, you mentioned a trade-off where syntax means the same thing in many different positions, vs. syntax whose meaning may be ambiguous in isolation, but enclosing constructs (context) is used to disambiguate. It's implied that it is better to be unambiguous even though it may be more verbose. As a starting point, I totally agree with this!

However, I'd like to promote the idea that it is OK to consider some slightly larger syntactic constructs when it is determined whether or not the nature of a construct can be discerned by a human reader.

For example, it is true that int x is a declaration of a parameter that induces a constant instance variable as well in the following code using primary constructors:

class const Point(int x, int y);

// Corresponds to:
class Point {
  final int x, y;
  const Point(this.x, this.y);
}

This means that nothing tells us, based on the syntax int x alone, that it is a declaring parameter, and that it is final. However, the fact that the construct (int x, int y) occurs in the class header is a very visible signal which indicates that it is a declaring parameter.

Similarly, the modifier const just before the class name indicates that the constructor is constant, and hence every instance variable must be final (so the primary constructors proposal allows final to be inferred).

It's true that a very narrow perspective on the parameter declaration itself makes int x very ambiguous, but I'd claim that we have strong signals in the syntax nearby which will help a reader who has any familiarity with this kind of constructor to see it.

Next, we have a proposal that required can be inferred. With that, we can write a variant of Point where required is allowed, but will be inferred if not specified:

class const PointNamed({int x, int y});

// Corresponds to:
class PointNamed {
  final int x, y;
  const PointNamed({required int x, required int y});
}

The inference mechanism simply makes a named parameter required implicitly in the situation where it would have been a compile-time error to omit it.

So the point is that we should look somewhat further than the syntax of each individual parameter declaration in order to determine whether the syntax is explicit enough to be readable. I think that's compatible with the way our brains are working, too.

Emphasis on brevity

Primary or declaring constructors do not contribute expressive power, we can already express exactly the same entities today, using declarations that are more verbose.

For this reason, I tend to emphasize the brevity of this mechanism. When the language principle above has been taken into account, it'll be concise and readable.

I consider non-declaring parameters in a primary/declaring constructor to be a questionable feature. @munificent already mentioned in the OP that they are pretty much useless in an in-header primary constructor. In a large in-body constructor it may be a source of confusion that the list of parameters can't be read as a list of variable declarations.

So I'd be perfectly happy if we would drop the support for non-declaring parameters. A declaring/primary constructor would then be both concise, and easy to read and understand.

I can see that @rrousselGit said something similar here.

Embrace non-declaring parameters?

If we do make the choice to support non-declaring parameters in primary/declaring constructors then I think it makes sense to use a clear syntactic signal about which parameter is declaring and which one is not.

I'm worried about the use of final to indicate that the parameter declares a final instance variable. It is a breaking change to remove the support for final parameters in general (the ones that can't be mutated in the body), and we would do this just in order to allow constructors to use the same syntax with a different semantics.

The syntax var int x is better because it is new, hence unambiguous.

Using the syntax this.x to indicate that a parameter is declaring is also ambiguous: It could have the current semantics (where there is an instance variable declaration named x already) or the new one (where the parameter implicitly induced that instance variable declaration). My main worry is that a mere typo in the name may change the meaning of the declaration: It was intended to initialize an existing

Also, the use of this.x to mark a parameter as declaring in one constructor, and using it to initialize an existing a few lines further down (in some other constructor, or even the same one), that's definitely a source of confusion. Also, I'm worried that a simple typo in the variable name can silently introduce a new variable and leave the old one uninitialized (which will also be silent if it is nullable and mutable).

The proposal here by @kmccur is quite interesting because it uses a colon to make the declaring constructors. This may look unfamiliar (except that patterns do already allow for similar a syntax today, also with a declaring semantics), but it is concise and highly visible.

In any case, if we're going to have non-declaring parameters then I'd love to use var and val to do it, and then (in the future) look for opportunities to allow val everywhere where final is currently used to declare an immutable variable.

In any case, the signal that makes a parameter declaring should definitely be concise.

Constructor size

We have discussed two major cases for declaring/primary constructors:

  • Few instance variables are declared, few features are needed.
  • Many instance variables are declared, and features like assertions or initializer lists are needed.

The interesting fact is that these constructors are linearly more useful the more variables they are declaring (because it's a long list of names for which we don't have to declare an instance variable as well as a parameter).

I think we'll have both small simple (data) classes where an in-header primary/declaring constructor would work very well, and also large classes with lots of instance variables, where an in-body constructor would probably be preferred, simply because the class header gets harder to read when it contains a large amount of text.

It makes sense to have different syntax for these two forms. In particular, the syntax for the in-header form should be more restricted because a huge and complex class header isn't readable, and the in-body form should allow for more features because it can be useful, and it isn't less readable than other constructors.

Summary

I think the near syntactic context can (and should) play a constructive role for a reader. In particular, the modifier const on an in-header or in-body declaring/primary constructor can imply that the instance variables declared by this constructor are final. Similarly, the modifier required can be inferred in the cases where it would be a compile-time error to omit it.

These things allow the mechanism to be more concise, which is useful given that this is the purpose of this mechanism in the first place.

For the same reason, I'd be perfectly happy to omit the support for non-declaring parameters (other than super. and this.).

However, if we do want to embrace non-declaring parameters then we have a really hard syntactic problem. ;-D

Reinterpreting final int x to mean "this parameter is declaring" conflicts with the current meaning of that syntax. var int x is better, and it could go well together with val int x. We could use int this.x, but this is error prone because this syntax already has a meaning (which will be preserved and exist along with the new meaning). Finally :int x could be used, and there could be more.

Do we really want non-declaring parameters so badly that all the declaring/primary constructors in the world must pay for it in terms of including some designating keyword like var/final/:/val? I tend to think that the support for this. and super. (with the current semantics) offers a good trade-off, with excellent brevity and slightly limited expressive power.

eernstg avatar Jun 10 '25 18:06 eernstg

Heres another idea. what if we introduce a local keyword (as @Wdestroier suggests) but it works like this and super.

class A(int x);

class B(int super.x, int this.y, int local.z) extends A;

We could then say that for primary constructors this. is the default and can be omitted. For other constructors local. is the default and can be omitted.

mmcdon20 avatar Jun 10 '25 20:06 mmcdon20

Interesting! So this.x would be declaring and local.x would be non-declaring? It does make sense that the local. declaration is a parameter (only), which is considered to be just another kind of local variable in the language specification; but that might not be the first thing that comes to mind for anyone who isn't reading the language specification all the time. ;-)

We could also stick to var, relying on the hint that the declaration declares a parameter, but it also declares an instance variable (which may or may not be mutable).

In the following example, I'm assuming that this. is used in the same way as today, and in the same way as in the primary constructor proposal (that is, it will initialize an instance variable which has been declared somewhere else). The new thing is that int var.x is a declaring parameter.

// Declaring because of `var.`.
class A1(int var.x);

// Declaring because it's the default for an in-header constructor.
class A2(int x);

class B(super.x, this.y, int var.z) extends A2 {
  int y;
}

// Declaring because of `var.`, final because of `final`.
class C1(final int var.x);

// Declaring because it's the in-header default, `final` because of `const`.
class const C2(int x);

class D extends C2 {
  final int y;
  final int u;
  const this(super.x, this.y, int var.z, int w): u = w + 1;
}

In-header constructors could use var. consistently to mark declaring parameters (we could have a lint for that), but they could also rely on the default to get the same effect (and we could also have a lint to always omit var. when in-header). In any case, in-header constructors would be unable to declare any non-declaring parameters other than super. and this..

An in-body primary/declaring constructor would have to use var. with every declaring parameter, because the default is to be non-declaring. In return for that extra verbosity we get the ability to declare non-declaring parameters, which goes well together with the fact that such a constructor can have an initializer list and a body.

Arguably, there are a couple of drawbacks in this syntax.

The var. declaring markers aren't aligned, and final declaring parameters need both keywords (when final can't be implied by making the constructor constant):

class SomeVeryLongClassName {
  final int oneField;

  this({
    int oneField, // Not declaring.
    final String var.anotherField,
    double var.theThirdField, // Not final.
    final bool var.soManyFields,
    final List<String> var.okLastOne,
  }): oneField = oneField.abs();
}

Looking at this, it might actually be quite good for the overall readability that var.anotherField is so tightly coupled, reminding the reader that this parameter also declares an instance variable.

Again, required is inferred for every parameter because its declared type isn't nullable.

eernstg avatar Jun 11 '25 08:06 eernstg

Do we really need an "in-body constructor"?
What if instead, we had a mean to tell a constructor to inherit all parameters from its primary constructor?

We could then define:

class Person._({int age, String name}) {
  Person(..., {int? virtual})
    : field = virtual ?? DateTime.now();

  final int field;
}

The idea would be that ... inside a constructor would insert all of the primary constructor parameters, and append the other parameters.

Positional resolution would be based off if a new positional parameter is before or after the ...:

class Person._(int b) {
  Person(int a, ..., int c)
}

And of course, this would reject mixing optional positionals with named positionals.

rrousselGit avatar Jun 11 '25 09:06 rrousselGit

I really like the idea of decreasing the redundancy when declaring multiple constructors. I prefer a this.* syntax that I find more expressive than ...

cedvdb avatar Jun 11 '25 11:06 cedvdb

Interesting! So this.x would be declaring and local.x would be non-declaring?

Yes, that is correct.

It does make sense that the local. declaration is a parameter (only), which is considered to be just another kind of local variable in the language specification; but that might not be the first thing that comes to mind for anyone who isn't reading the language specification all the time. ;-)

One thing I like from this approach is that we can allow local. in the older style of constructor also, and that way primary constructors and old constructors have essentially the same syntax, the only difference being what they are allowed to omit.

class A(int x);

// (int super.x, int this.y, int local.z) in all cases
class InHeaderExplicit(int super.x, int this.y, int local.z) extends A;
class InBodyExplicit extends A { this(int super.x, int this.y, int local.z); }
class OldExplicit extends A { 
  OldExplicit(int super.x, int this.y, int local.z); 
  int y; 
}

// omitting keywords where possible
class InHeaderImplicit(super.x, int y, int local.z) extends A;
class InBodyImplicit extends A { this(super.x, int y, int local.z); }
class OldImplicit extends A { 
  OldImplicit(super.x, this.y, int z); 
  int y; 
}

Edit: @eernstg

In your var.x style, in order to get a final declaration it should probably be int final.x instead of final int var.x.

Your SomeVeryLongClassName would then be

class SomeVeryLongClassName {
  final int oneField;

  this({
    int oneField, // Not declaring.
    String final.anotherField,
    double var.theThirdField, // Not final.
    bool final.soManyFields,
    List<String> final.okLastOne,
  }): oneField = oneField.abs();
}

You would still be allowed to write final at the start but it would apply to the parameter not the field.

mmcdon20 avatar Jun 11 '25 11:06 mmcdon20

Do we really need an "in-body constructor"?

I definitely want one.

It makes transitioning between an in-header primary constructor and an in-body constructor easier, you just move the declaration and remove the type parameters, then you can start adding asserts and a body if you want to.

It allows you to keep the benefit of avoiding the duplication of writing the same name twice/thrice/quarce when you declare a field and initialize it in the only constructor, while still having asserts or a body.

To me the big question is: Do we need an in-header primary constructor?

It allows shorter simple data classes, like final class const Point(int this.x, int this.y);. That's nice. But not that much bigger than

final class Point { const this(int this.x, int this.y); }

(It's a choice to format that on more than one line, not a law of nature.)

lrhn avatar Jun 11 '25 12:06 lrhn

I'm personally worried that we'll end up with "OK, you can use this to declare factory constructors and extension type constructors, but that's the best we can do."

kmccur avatar Jun 11 '25 13:06 kmccur

@lrhn

I definitely want one.

It makes transitioning between an in-header primary constructor and an in-body constructor easier, you just move the declaration and remove the type parameters, then you can start adding asserts and a body if you want to.

We can make the transition simple without adding yet another way of defining constructors.

Take my ... proposal. We could have:

class Person({int age, String name}) {}
// Strictly equivalent to
class Person({int age, String name}) {
  Person(...);
}

Specficially, the header wouldn't count as a constructor. Instead it'd define the behaviour of ... inside constructors. And alongside the change, we'd also change the default synthetic constructor that is added to all Dart classes without a constructor such that we have:

// No constructor, Dart automatically adds one
class ClassName {}
// The added constructor is now behaving like this:
class ClassName {
  ClassName(...);
}

This means that we have:

class Example(int a) {
  Example.named(): a = 42;
}

Example(42); // KO: No Example(...) constructor, as since we added .named, the default synthetic constructor is no-longer added
Example.named(); // OK

---

class Example(int a) {
  Example(...);
  Example.named(): a = 42;
}

Example(42); // OK
Example.named(); // OK

---

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

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

---

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

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

---

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

"Transitioning from header" is simple here. For starter, the header wouldn't be removed during the transition.

You'd just change class Foo(int a); to class Foo(int a) { Foo(...); } and customising constructors to add asserts and such. The refactoring is simple enough that you wouldn't need any IDE tooling here.

To me the big question is: Do we need an in-header primary constructor?

It allows shorter simple data classes, like final class const Point(int this.x, int this.y);. That's nice. But not that much bigger than

That's not representative of what I personally expect from "in-header". I've said it before, but I'd expect in-header to behave like Records https://github.com/dart-lang/language/issues/4396#issuecomment-2952119211

So:

  • No this.field to "opt-in" to field definition.
  • No final keyword.
  • No required keyword.

Ultimately, we should consider sealed hierarchies ; which is probably one of the key use-cases.

Consider:

sealed class Result<T> {
  case class Data(T value);
  case class Error({Object error, StackTrace stackTrace});
}

vs:

sealed class Result<T> {
  case class Data {
    this(final T this.value);
  }
  case class Error {
    this({required final Object this.error, required final StackTrace this.stackTrace});
  }
}

Nit:

I don't like relying on const to imply "all fields are final". I often purposefully do not support const in my classes, even when they can use it. Because in the future I may need to add an assert or initializer to the class. And if I added const initially, removing it would be a breaking change.

That means I'd have to eat the boilerplate because I don't want to add that const keyword.

rrousselGit avatar Jun 11 '25 13:06 rrousselGit

^ I've described this proposal in detail in a separate issue: https://github.com/dart-lang/language/issues/4405

rrousselGit avatar Jun 11 '25 14:06 rrousselGit

It allows shorter simple data classes, like final class const Point(int this.x, int this.y);. That's nice.

A record would be even shorter.

Personally I don't see the added value of the header part. All I'm after is to be able to declare field in the constructor, shorter this instead of MyClassName and the ability to set private fields with this._x. The ... syntax proposed above would also work without header.

cedvdb avatar Jun 11 '25 14:06 cedvdb