Did you hear? Dart 3.12 added experimental support for primary constructors, a new language feature that can remove a lot of small, repetitive constructor boilerplate from our codebases!
If you've written enough Dart, you've probably seen this pattern thousands of times:
class Point {
final int x;
final int y;
Point(this.x, this.y);
}
This code is quite repetitive: we declare the fields, then we repeat the same names again in the constructor.
With primary constructors, this becomes:
class Point(final int x, final int y);
Much shorter, and the call site stays exactly the same:
final point = Point(1, 2);
This is just a little taster. But primary constructors are not just for toy examples like this.
This article walks you through the most common use cases, so you can get familiar with the new syntax. A follow-up article will cover how to migrate your entire codebase quickly and safely. Let's dive in!
What Can Be Migrated?
To get a better feel for this feature, let's explore some more examples from a recent migration on one of my projects.
Parameter Lists of Varying Shapes
Named parameters, required, and default values can all be preserved:
class CurrencyRates {
CurrencyRates({
required this.amount,
this.base = Currency.usd,
required this.date,
required this.rates,
});
final double amount;
final Currency base;
final DateTime date;
final Map<Currency, double> rates;
factory CurrencyRates.fromFrankfurterApi(Map<String, dynamic> json) {
// Parse API response...
}
}
Becomes:
class CurrencyRates({
required final double amount,
final Currency base = Currency.usd,
required final DateTime date,
required final Map<Currency, double> rates,
}) {
factory fromFrankfurterApi(Map<String, dynamic> json) {
// Parse API response...
}
}
The constructor API shape stays the same, including the named arguments and the default value for base. Any additional factory or named constructors remain unchanged.
Enhanced Enums
Enhanced enums are a great fit for primary constructors because enum values often repeat the same constructor shape:
enum ChartTimeRange {
oneWeek('1W', 7, 0, 0),
oneMonth('1M', 0, 1, 0),
threeMonths('3M', 0, 3, 0),
oneYear('1Y', 0, 0, 1),
fiveYears('5Y', 0, 0, 5),
all('ALL', 0, 0, 0),
;
const ChartTimeRange(this.label, this.days, this.months, this.years);
final String label;
final int days;
final int months;
final int years;
}
Becomes:
enum ChartTimeRange(final String label, final int days, final int months, final int years) {
oneWeek('1W', 7, 0, 0),
oneMonth('1M', 0, 1, 0),
threeMonths('3M', 0, 3, 0),
oneYear('1Y', 0, 0, 1),
fiveYears('5Y', 0, 0, 5),
all('ALL', 0, 0, 0),
;
}
This keeps the enum values unchanged while removing the separate constructor and field declarations.
Sealed Hierarchies With Semicolons
Here's an example of a sealed hierarchy representing the results of a database operation:
sealed class DatabaseIOResult {}
class DatabaseIOSuccess extends DatabaseIOResult {
DatabaseIOSuccess({this.path});
final String? path;
}
class DatabaseIOCancelled extends DatabaseIOResult {}
class DatabaseIOError extends DatabaseIOResult {
DatabaseIOError(this.message);
final String message;
}
When migrating to primary constructors, this becomes:
sealed class DatabaseIOResult;
class DatabaseIOSuccess({final String? path}) extends DatabaseIOResult;
class DatabaseIOCancelled extends DatabaseIOResult;
class DatabaseIOError(final String message) extends DatabaseIOResult;
Note how the empty body {} was replaced with a semicolon ;, which is more concise.
Private Parameters
Before Dart 3.12, initialization of private fields was rather clunky:
class SnapshotRepository {
SnapshotRepository(AppDatabase db) : _db = db;
final AppDatabase _db;
...
}
With the introduction of the private named parameters feature, this becomes:
class SnapshotRepository {
SnapshotRepository(this._db);
final AppDatabase _db;
...
}
Primary constructors take this a step further, by declaring private fields directly in the class header:
class SnapshotRepository(final AppDatabase _db) {
...
}
Either way, this class can then be instantiated like this:
final snapshotRepository = SnapshotRepository(AppDatabase());
Note: this feature works with both positional and named private parameters.
Field Initializers
Primary constructor parameters are also in scope for field initializers inside the class body.
That means constructor initializer lists like this:
class AppRobot {
AppRobot(this.tester)
: navigation = NavigationRobot(tester),
onboarding = OnboardingRobot(tester),
chooseInvestment = ChooseInvestmentRobot(tester),
investmentForm = InvestmentFormRobot(tester);
final WidgetTester tester;
final NavigationRobot navigation;
final OnboardingRobot onboarding;
final ChooseInvestmentRobot chooseInvestment;
final InvestmentFormRobot investmentForm;
}
Can become:
class AppRobot(final WidgetTester tester) {
final navigation = NavigationRobot(tester);
final onboarding = OnboardingRobot(tester);
final chooseInvestment = ChooseInvestmentRobot(tester);
final investmentForm = InvestmentFormRobot(tester);
}
Here, tester is available to the field initializers even though there is no separate constructor body.
Constructor Declaration Shorthand
Not every constructor can move into the class header.
For redirecting constructors that need to stay in the class body, Dart supports constructor declaration shorthand with new.
For example, this code:
part 'app_database.g.dart';
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(impl.connect());
AppDatabase.forTesting(super.e);
}
Becomes:
part 'app_database.g.dart';
class AppDatabase extends _$AppDatabase {
new() : super(impl.connect());
new forTesting(super.e);
}
Named Constructors
When dealing with named constructors, only one of them can become a primary constructor, while others are migrated with the new declaration shorthand.
This means that this code:
class AddInvestmentChoice {
const AddInvestmentChoice._({this.apiSymbol});
const AddInvestmentChoice.custom() : this._();
const AddInvestmentChoice.apiBacked(SupportedApiSymbol symbol) : this._(apiSymbol: symbol);
final SupportedApiSymbol? apiSymbol;
}
Becomes:
class const AddInvestmentChoice._({final SupportedApiSymbol? apiSymbol}) {
const new custom() : this._();
const new apiBacked(SupportedApiSymbol symbol) : this._(apiSymbol: symbol);
}
In this specific case:
- the private
_named constructor has been migrated to a primary constructor (thus absorbing theapiSymbolfield) - the
customandapiBackednamed constructors have been migrated to thenewsyntax
Flutter Widgets with super.key
Flutter widget constructors can also become primary constructors:
class PortfolioTrackerApp extends StatelessWidget {
const PortfolioTrackerApp({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
Becomes:
class const PortfolioTrackerApp({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
The constructor remains const. Primary constructor syntax puts const between class and the class name, which looks unusual at first but keeps const call sites valid.
Const Primary Constructors
One detail that is easy to miss: const is supported, but the syntax is different.
The const keyword goes between class and the class name.
For example, this code:
class Insets {
final double value;
const Insets(this.value);
}
const small = Insets(8);
Can be migrated to this without removing const:
class const Insets(final double value);
const small = Insets(8);
This matters because const affects:
- Compile-time constant construction
- Canonicalization
- Required const contexts, such as annotations and default parameter values
- Flutter widget allocation, canonicalization, and lint expectations
So a safe migration should preserve const, not drop it.
Why This Matters In Flutter
Flutter codebases often have many immutable classes and widgets like this:
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
}
Could this be shorter with primary constructors?
Yes.
And the const constructor can be preserved:
class const PrimaryButton({
super.key,
required final String label,
required final VoidCallback? onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
}
Other Migration Scenarios
This article is not about showing every single possible migration scenario. For that, you can check the primary constructors feature specification, which includes a very precise mapping of all constructor forms that can be migrated:
Original Dart syntax New abbreviated syntax
--------------------------------------- --------------------------
LongClassName() {} new() {}
LongClassName.name() {} new name() {}
const LongClassName(); const new();
const LongClassName.name(); const new name();
LongClassName(): this.other(); new(): this.other();
LongClassName.name(): this(); new name(): this();
const LongClassName(): this.other(); const new(): this.other();
const LongClassName.name(): this(); const new name(): this();
factory LongClassName() { ... } factory() { ... }
factory LongClassName.name() { ... } factory name() { ... }
factory LongClassName() = D; factory() = D;
factory LongClassName.name() = D; factory name() = D;
const factory LongClassName() = D; const factory() = D;
const factory LongClassName.name() = D; const factory name() = D;
But one question remains. 👇
Should You Migrate Every Class, Enum, and Widget in Your Codebase?
As we have seen, the new syntax is quite compact, and if you want to modernize your Dart codebase, primary constructors are a great way to go.
But as of Dart 3.12, primary constructors are available as an experimental feature, and there is no dart fix for this (after all, the old syntax is still valid).
On real projects, a safe migration has to preserve constructor shapes, const behavior, comments, generated-file boundaries, public API compatibility, and much more.
Fixing this by hand is tedious, and completely impractical on large codebases.
The Solution: A Dart Migration CLI
In my opinion, creating some automated migration tooling is the right way to go.
So, over the last week, I've built a new Dart migration CLI that can safely migrate entire codebases, and do it fast!
To test this at scale, I created bizz84/flutter-gallery, a fork of the original Flutter Gallery project, which is no longer maintained.
What's interesting about this repository is that it's quite big, featuring over 300,000 lines of code.
Yet, I was able to migrate it in under one second, and this is the result:

If you're curious, you can check out the final PR for this migration here:
Conclusion
Primary constructors are a nice quality-of-life improvement for Dart. They can remove a lot of constructor boilerplate from classes, enums, sealed hierarchies, and Flutter widgets.
When it comes to migrating your codebase, you have four options:
- Do nothing (support is still experimental for now)
- Migrate by hand (tedious and error-prone)
- Write a coding agent skill (error-prone, token-intensive, and doesn't scale to large codebases)
- Use my new Dart migration CLI (fast, safe, and scales to large codebases)
My migration CLI is nearly ready for release. If you want to learn more, watch out for my next article, where I'll go deeper into these migration strategies.
Thanks for reading, and happy coding!





