Dart 3.13 makes primary constructors a stable language feature. You can now declare a class's main constructor and its fields in the class header, replacing repetitive model code with a compact form that keeps the same runtime behavior.
For Flutter developers, the best early use cases are small immutable value objects, configuration models, DTOs, and enhanced enums. The syntax is not a reason to rewrite every class: it works best when one constructor clearly represents the object's primary initialization path.
This guide uses code analyzed and executed with the official Dart 3.13.0 SDK on Windows x64. It also shows the language-version boundary, migration constraints, and cases where a traditional constructor remains clearer.
- What Changed in Dart 3.13
- Check Your Project Before Adopting the Syntax
- Declare Immutable Flutter Models Concisely
- Derive Fields and Validate Input
- Build Const Value Objects
- Forward Superclass Parameters
- Simplify Enhanced Enums
- Know the Important Constraints
- Migrate Incrementally
- Tested Example
- A Practical Decision Rule
- Official References
What Changed in Dart 3.13
Before Dart 3.13, a simple model repeated each field in the class body and constructor:
class ApiConfig {
final Uri baseUrl;
final Duration timeout;
ApiConfig({
required this.baseUrl,
this.timeout = const Duration(seconds: 10),
});
}
A primary constructor moves the parameter list to the class header. Adding final or var to a parameter makes it a *declaring parameter*: Dart creates and initializes the corresponding instance field.
class ApiConfig({
required final Uri baseUrl,
final Duration timeout = const Duration(seconds: 10),
});
This is syntax sugar. It removes declaration boilerplate but does not add value equality, generated copyWith methods, serialization, or different runtime semantics. Packages that provide those features can still be useful.
Primary constructors require a language version of at least 3.13. Dart 3.12 offered an experimental preview behind a flag, but production code should use the stable Dart 3.13 syntax without the old experiment flag.
Check Your Project Before Adopting the Syntax
First verify the Dart SDK used by the project:
dart --version
In a Flutter project, run the command through the same Flutter installation used by CI. A globally installed Dart executable and Flutter's bundled Dart SDK can be different versions.
Then make the minimum SDK constraint explicit in pubspec.yaml:
environment:
sdk: ^3.13.0
Run dependency resolution and static analysis after changing the constraint:
dart pub get
dart analyze
For a Flutter application, use flutter pub get and flutter analyze instead. Do not merge primary-constructor syntax until every developer machine and CI job uses a toolchain that supports Dart 3.13.
The version boundary is enforced by the analyzer. In the validation for this article, the same syntax with // @dart=3.12 produced experiment_not_enabled, while Dart 3.13.0 analyzed it successfully.
Declare Immutable Flutter Models Concisely
Named parameters are often the clearest choice for application models:
class ApiConfig({
required final Uri baseUrl,
final Duration timeout = const Duration(seconds: 10),
}) {
Uri endpoint(String path) => baseUrl.resolve(path);
}
Both parameters induce final fields. The method reads baseUrl exactly as it would in a traditional class.
final config = ApiConfig(
baseUrl: Uri.parse('https://api.example.com/v1/'),
timeout: const Duration(seconds: 5),
);
print(config.endpoint('users/42'));
print(config.timeout.inSeconds);
The tested output was:
https://api.example.com/v1/users/42
5
Use final by default for immutable state. Use var only when the field itself should remain assignable after construction:
class DownloadState(var double progress);
final state = DownloadState(0.25);
state.progress = 0.5;
A parameter without final or var is available during initialization but does not create a field. That distinction is useful for derived values.
Derive Fields and Validate Input
Primary constructor parameters are in scope for non-late field initializers. A model can retain a normalized or derived value without storing every raw input:
class AssetPath(String rawPath) {
final String normalized = rawPath.trim().replaceAll('\\', '/');
}
For assertions or a constructor body, add a this section inside the class:
class PageRequest(
final int page,
final int pageSize,
) {
this : assert(page > 0), assert(pageSize > 0 && pageSize <= 100);
int get offset => (page - 1) * pageSize;
}
The this : ...; form is the primary constructor's initializer list. If initialization needs executable statements, use a block:
class Session(var String token) {
this {
token = token.trim();
if (token.isEmpty) {
throw ArgumentError.value(token, 'token', 'must not be empty');
}
}
}
Inside the body, a declaring parameter such as token refers to the induced field, so assigning to it updates the field. In an initializer expression, the name refers to the incoming parameter. This scope difference is deliberate, but a long or stateful initialization path may be easier to understand with a traditional constructor.
When a Traditional Constructor Is Clearer
Use the body form for short validation or normalization. If construction performs several conditional steps, talks to external services, or mutates multiple fields, the familiar constructor layout usually makes those responsibilities easier to review and test.
Build Const Value Objects
For immutable objects that can be compile-time constants, put const before the class name:
class const CacheKey(final String namespace, final String value) {
final String serialized = '$namespace:$value';
}
const key = CacheKey('user', '42');
All instance fields in a constant primary-constructor class must be final, non-late, and definitely initialized. A const primary constructor can have an initializer list, but it cannot have a body block.
This pattern fits small Flutter configuration objects, style tokens, route identifiers, and immutable keys. It does not automatically implement operator == or hashCode; add those manually or use an appropriate code-generation solution when value equality is required.
Forward Superclass Parameters
Super parameters work in a primary constructor, which can make a small class hierarchy much easier to scan:
class User(final String name, final int age);
class Developer(
super.name,
super.age,
final String language,
) extends User;
Calling Developer('Mina', 29, 'Dart') initializes name and age in User, then declares language on Developer. The tested program printed:
Mina:29:Dart
Keep inheritance shallow. Primary constructors reduce syntax, but they do not make a complicated class hierarchy simpler at the design level.
Simplify Enhanced Enums
Enhanced enums can also declare their fields and constructor in the header:
enum BuildMode(final String label) {
debug('Debug'),
profile('Profile'),
release('Release');
}
Enum primary constructors are implicitly constant. The example above was analyzed and executed with Dart 3.13.0, and BuildMode.release.label returned Release.
This is a practical fit for Flutter build modes, display labels, route types, and other closed sets that carry a small amount of metadata.
Know the Important Constraints
The concise syntax has a few rules that matter during migration:
| Constraint | Practical consequence | |—|—| | final and var mark declaring parameters | They are no longer valid parameter modifiers in ordinary methods or top-level functions | | A class has one primary construction path | Other non-factory generative constructors must redirect to it | | Declaring parameters cannot be late or external | Declare those fields in the class body | | A field cannot be initialized twice | Do not combine a declaring parameter with another initializer for the same field | | A const primary constructor cannot have a body | Use an initializer list or keep a traditional design | | Empty class bodies can use a semicolon | class Point(final int x, final int y); is valid |
One Dart 3.13 migration issue is broader than primary constructors: final and var are now reserved for declaring parameters in primary constructor headers. Code such as this is rejected:
void logValue(final String value) {
print(value);
}
Remove the modifier:
void logValue(String value) {
print(value);
}
If preventing parameter reassignment is a team convention, the Dart documentation recommends the parameter_assignments lint instead of the old modifier pattern.
Migrate Incrementally
Primary constructors are source-compatible only when the package language version is 3.13 or newer. A safe migration sequence is:
- Upgrade the Flutter/Dart toolchain on a branch and align local development with CI.
- Set the package's minimum Dart SDK constraint to 3.13.
- Run
dart analyzeorflutter analyzebefore changing model declarations. - Fix ordinary function parameters that still use
finalorvar. - Convert a few simple leaf models whose main constructor only initializes fields.
- Run unit, widget, and serialization tests.
- Review generated-code integrations before converting classes managed by a generator.
Do not mechanically convert every constructor. Keep the traditional form when:
- several named constructors are equally important;
- initialization has many branches or side effects;
- a code generator owns the declaration;
- framework conventions or team readability favor the established syntax;
- moving a long parameter list into the class header makes the declaration harder to scan.
Because primary constructors do not change runtime behavior, a performance benchmark would not provide a meaningful reason to adopt them. The benefit is less repetition and a clearer declaration when the class has one obvious initialization path.
Tested Example
The complete validation sample combined a named ApiConfig, a const CacheKey, superclass forwarding, and an enhanced enum. It was checked with the official Dart 3.13.0 Windows x64 SDK:
Dart SDK version: 3.13.0 (stable) on windows_x64
dart analyze primary_constructors.dart
No issues found!
dart format --output=none --set-exit-if-changed primary_constructors.dart
Formatted 1 file (0 changed)
The SDK archive's SHA-256 matched the checksum published beside the official archive. No benchmark is reported because the feature is a change in source syntax, not a new runtime optimization.
A Practical Decision Rule
Use a primary constructor when the class has one natural construction path and most parameters become fields directly. Start with immutable DTOs, configuration objects, small domain models, and enums.
Keep a traditional constructor when it tells the story more clearly. Dart 3.13 gives Flutter teams a new option for concise models, not a mandate to replace familiar code. Adopt it where the shorter declaration improves reviewability, and let analysis plus tests protect the migration.


Comment