跪拜 Guibai
← Back to the summary

Dart 3.13's Primary Constructors Are a Syntax Rewrite, Not Just Sugar

It feels like Dart 3.13 might just be one new syntax feature to the outside world, but Dart's core designers even wrote a super-long article about Primary Constructors. If you only look at the demo examples, this change doesn't seem like much. For example, with Primary Constructors, it's like this:

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

Previously, we wrote it like this:

class Point {
  final int x;
  final int y;

  Point(this.x, this.y);
}

It looks like a few lines of code saved, as if Dart borrowed some syntactic sugar design from Kotlin or Scala again. But for Dart's designers, this is a restructuring process of Dart's syntax.

This change is actually the Dart team using the implementation process of Primary Constructors to reorganize the relationship between Dart's 'classes, fields, parameters, and constructors' — a significant refactoring for Dart.

So what Dart 3.13 ultimately delivers is much bigger than a single line of class Point(...). It includes:

In fact, starting from Dart 3.13, the way constructors are written will lead to a fairly significant style migration across Dart and Flutter.

So why, after so many years of people saying Dart doesn't provide a Data Class like Kotlin, did they instead make Primary Constructors?

Indeed, the most popular feature request in the Dart language repository for all these years has been Data Class, similar to Kotlin's:

data class User(
    val name: String,
    val age: Int
)

This automatically declares fields and constructors, and also provides a whole set of value semantics like equals(), hashCode(), toString(), copy().

But after sorting through the entire historical discussion, the Dart team found that what many Dart users most wanted to solve was actually the first half: defining a class to hold data is too cumbersome. For example, a traditional Dart data object looks like:

class Point {
  final int x;
  final int y;

  Point(int x, int y)
      : x = x,
        y = y;
}

The concept of a single x could appear four times in a simple class. So later, Dart added initializing formals:

class Point {
  final int x;
  final int y;

  Point(this.x, this.y);
}

This was already simplified, but fields still needed to be declared once, and constructor parameters appeared again. So the Dart team promptly split the problem into two things:

So the goal of Dart 3.13's Primary Constructors is to solve the first problem first, performing a compression of code expression.

In fact, the core of Primary Constructors is really Declaring Parameters. The simplest understanding of a Primary Constructor is moving the constructor to the class header:

class Point(int x, int y);

However, there's a point that's easy to overlook here. The int x and int y in this code are just constructor parameters, they do not automatically become fields. What's truly key is the declaring parameter introduced in Dart 3.13:

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

The final here conveys two pieces of information simultaneously: constructor parameter and final instance field. This is equivalent to:

class Point {
  final int x;
  final int y;

  Point(this.x, this.y);
}

If a field needs to be mutable, then use var:

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

This is actually equivalent to:

class Point {
  int x;
  int y;

  Point(this.x, this.y);
}

So, the Dart team has actually divided primary constructor parameters into several roles:

class Example(
  final int a,
  var int b,
  int c,
);

Where:

This is very important, because Primary Constructors do not stipulate that 'all parameters in the header are fields'. Parameters can still only participate in initialization calculations, for example:

class Rectangle(
  final double width,
  final double height,
  double scale,
) {
  final double area = width * height * scale;
}

Here, width and height will become instance fields, while scale only exists during the construction phase.

So, understanding it might actually be slightly more complex than before. The official specification even added a new scope, called the primary initializer scope, allowing ordinary primary constructor parameters to be used by field initializers. For example, previously:

class DeltaPoint {
  final int x;
  final int y;

  DeltaPoint(this.x, int delta)
      : y = x + delta;
}

Now you can write:

class DeltaPoint(
  final int x,
  int delta,
) {
  final int y = x + delta;
}

Here, delta did not become a field, but the field initializer can read it directly.

That is, many simple calculations that previously could only be placed in the constructor initializer list can now even be moved back next to the field declaration.

So why didn't Dart just copy Kotlin directly? This set of features clearly has shades of Kotlin, for example:

Kotlin:

class Point(
    val x: Int,
    val y: Int
)

Dart:

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

In fact, the Dart team really struggled with this issue for quite a while: should the constructor be derived from 'fields', or should fields be derived from 'constructor parameters'?

For instance, Swift leans more towards the other direction: 'declare fields first', then the 'compiler generates a memberwise initializer'.

Dart ultimately chose to 'declare the constructor API first', and then 'some parameters incidentally generate fields'. The reason is actually quite direct: because the Constructor itself is often a public API, and developers may need more precise control over:

named / positional
required / optional
default value
parameter order
constructor name
const

These things all naturally belong to the constructor signature. If the constructor were automatically derived from fields, there would be more problems to handle, such as:

So Dart finally concluded that letting developers explicitly write the constructor signature, and then using var / final to mark which parameters also produce fields, is a more natural combination.

This is also why Dart's Primary Constructors look slightly 'heavier' than in some other languages. It doesn't pursue class Point(int x, int y) automatically guessing that x and y are fields. You must explicitly write:

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

Or:

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

The existence and mutability of fields need to be directly exposed in the source code.

Of course, one thing that must be mentioned here is this {}. The Dart team talks about the syntactic cliff.

For example, you start with a very simple class:

class FormatterOptions({
  final int indent = 0,
  final int pageWidth = 80,
});

But as requirements slowly increase, it becomes:

class FormatterOptions({
  final int indent = 0,
  final int pageWidth = 80,
  final bool followLinks = false,
  final bool setExitIfChanged = false,
});

Then one day, you suddenly need to print a line of log in the constructor. If Primary Constructors only supported 'simple classes without a body', you might need to rewrite the entire class back to:

class FormatterOptions {
  final int indent;
  final int pageWidth;
  final bool followLinks;
  final bool setExitIfChanged;

  FormatterOptions({
    this.indent = 0,
    this.pageWidth = 80,
    this.followLinks = false,
    this.setExitIfChanged = false,
  }) {
    log.write('Created options.');
  }
}

Semantically, only one line log.write(...) was added, but the actual Git diff suddenly becomes a dozen or twenty lines. This is the syntactic cliff:

A very small functional change causes the code to suddenly fall from a concise syntax into another, very verbose syntax.

So Dart designed a constructor body for Primary Constructors:

class FormatterOptions({
  final int indent = 0,
  final int pageWidth = 80,
}) {
  this {
    log.write('Created options.');
  }
}

The this here is the body of the Primary Constructor. If you need an initializer list, you can similarly write:

class Point(
  final int x,
  final int y,
) {
  this : assert(x >= 0);
}

You can even write:

class B(
  int x,
  int y,
  {required final String s2},
) extends A {
  final String s1;

  this
      : s1 = y.toString(),
        super.someName(x + 1);
}

So Primary Constructors are not limited to 'only writing DTOs'. You can also handle:

parameters
fields
default values
assert
initializer list
super constructor call
constructor body

This is also why the Dart team spent so long designing it. If it were just about doing class Point(final int x, final int y);, it wouldn't have been that complicated.

However, Primary Constructors do bring a problem. With Primary Constructors, this constructor is semantically truly primary. The official specification has a very important rule:

If a class has a primary constructor, then all other generative constructors must ultimately redirect to this primary constructor.

The reason is directly related to the field initializer scope mentioned earlier. For example:

class C(
  int value,
) {
  final int result = value * 2;
}

The premise for result = value * 2 to hold is that this primary constructor is always executed every time C is created. If C.other() were simultaneously allowed to completely bypass the primary constructor, then where value comes from would be undefined.

So Dart forces all generative constructors to go through the primary constructor. This is also the real structural difference between a Primary Constructor and 'another way of writing an ordinary constructor'.

If a class inherently has many constructors with equal status and completely different initialization paths, then continuing to use traditional in-body constructors is actually clearer.

The Dart team also explicitly stated that they do not intend for Primary Constructors to replace all constructors. For situations with many constructors, an already complex class header, or where the core constructor is private, the traditional approach is more reasonable.

Of course, Dart 3.13 actually also changed the way ordinary Constructors are written. Starting from Dart 3.13, ordinary constructors no longer need to repeat the class name. For example, previously:

class AnimatedFractionallySizedBox {
  AnimatedFractionallySizedBox();

  AnimatedFractionallySizedBox.create();

  factory AnimatedFractionallySizedBox.fromJson() {
    ...
  }
}

Now you can write:

class AnimatedFractionallySizedBox {
  new();

  new create();

  factory fromJson() {
    ...
  }
}

From the correspondence, you can see:

Old                              Dart 3.13

ClassName()                      new()
ClassName.name()                 new name()

const ClassName()                const new()
const ClassName.name()           const new name()

factory ClassName()              factory()
factory ClassName.name()         factory name()

This change is actually very 'Dart', because the biggest problem with the traditional C++ / Java / C# style constructor is:

class SomeExtremelyLongClassName {
  SomeExtremelyLongClassName();
}

The class name is already very clear in the context; writing it again provides almost no extra information. Moreover, this problem would be even more troublesome in Dart's future plans for static extension members, for example:

class SomeClass {}

typedef OtherName = SomeClass;

extension on OtherName {
  // constructor?
}

If extensions are allowed to add constructors in the future, should you write OtherName() or SomeClass() here? This would lead to issues with typedef identity, encapsulation, and name resolution. So the Dart team finally chose to directly dismantle this historical baggage. new() is the generative constructor declaration, and factory() is the factory constructor declaration.

This way, constructors no longer need to know the text name of the class. Of course, somewhat amusingly, it produces:

const new();

This const new does look a bit jarring at first glance.

Then, if the whole syntax set is placed into Flutter, a typical Widget previously would be:

class UserCard extends StatelessWidget {
  const UserCard({
    super.key,
    required this.name,
    required this.avatarUrl,
    this.showBadge = false,
  });

  final String name;
  final String avatarUrl;
  final bool showBadge;

  @override
  Widget build(BuildContext context) {
    ...
  }
}

If changed to a Primary Constructor, the style can be written similarly to:

class const UserCard({
  super.key,
  required final String name,
  required final String avatarUrl,
  final bool showBadge = false,
}) extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    ...
  }
}

Here, super.key continues to be a super parameter, while the three final parameters directly produce fields. The official specification explicitly supports super parameters:

class A(final int a);

class B(super.a) extends A;

What's more interesting is that when reading, the class's 'input API + saved state' is concentrated at the top:

class UserCard({
  super.key,
  required final String name,
  required final String avatarUrl,
  final bool showBadge = false,
}) extends StatelessWidget {

Seeing this section, you basically know what state this Widget holds. So, the value of this syntactic sugar isn't just about reducing character count; it also allows the code to expose intent more directly.

At the same time, Dart officially confirmed this will be the mainstream style for Dart in the future. Dart 3.13 even started pushing this new style through lints. That is, from the official supporting tools, the Dart team clearly does not treat Primary Constructors as a 'marginal syntax to use if you feel like it'. Dart 3.13 introduced six related lints at once:

empty_container_bodies
initialize_in_field_declaration
unnecessary_const_in_enum_constructor
unnecessary_primary_constructor_body
unnecessary_type_name_in_constructor
use_declaring_parameters

These lints all revolve around one direction: migrating code towards the shorter Primary Constructor expression. The IDE also directly added:

Convert to primary constructor
Convert to in-body constructor
Convert to declaring parameter
Move initialization to the field declaration

Particularly noteworthy is unnecessary_type_name_in_constructor. This lint will consider:

class C {
  C();
  C.name();
}

Should be changed to:

class C {
  new();
  new name();
}

The official documentation even directly marks the old way as BAD and the new way as GOOD. So, from a long-term trend perspective, the new() constructor declaration will likely gradually become the officially recommended Dart style. So don't think this is just adding a new syntax; in fact, this is very likely the beginning of a generational shift.

Additionally, there are two small pitfalls worth noting when upgrading to Dart 3.13. Although the official stance emphasizes that Primary Constructors do not add new runtime semantics, because the syntax space has changed, a small amount of source compatibility issues have arisen.

The first one here is final parameter. In the past, someone in Dart might have written:

void foo(final int value) {
  ...
}

Here, final was used as syntax for 'not allowing the parameter to be reassigned'. However, from Dart 3.13 onwards, final and var on parameters are given the special meaning of declaring parameters, so using them on ordinary function parameters will likely become a compile-time error.

This pitfall is a real trap. If a team wishes to continue restricting parameter assignment, the official recommendation is to use the parameter_assignments lint.

Another more niche situation is factory() {}. Previously, if you happened to define a method with no explicit return type and the exact name factory, the Dart 3.13 parser might interpret it as a factory constructor.

Of course, in ordinary Flutter projects, these two situations are generally not high-frequency, but it's still worth paying attention to.

So, Dart 3.13 is actually the beginning of another change on the scale of null safety. It's estimated that in another two versions, Dart might have a completely new generational break, so it's still necessary to pay attention.