Constructors in Dart
This is Part 2 of the Object-Oriented Dart series. In Part 1 we built our first class and waved our hands at "the constructor." Now we slow down, because constructors are where Dart is genuinely more powerful than most languages — and where beginners get tripped up the most.
A constructor is the special function that runs when you create an object. Its one job: take an object that exists but is empty, and leave it fully initialized and ready to use. Dart gives you several flavors, and each solves a real problem. Let's collect them all.
The default constructor
If you write a class and don't declare any constructor, Dart hands you a free one: the default constructor. It takes no arguments and does nothing but create the object.
class Empty {}
void main() {
var e = Empty(); // the default constructor at work
}
The moment you declare any constructor of your own, this freebie disappears. That detail matters later with inheritance, so file it away.
Generative constructors (the everyday one)
The "normal" constructor — the one that actually builds and initializes a fresh instance — is called a generative constructor. The verbose form spells everything out:
class Point {
double x;
double y;
Point(double x, double y) {
this.x = x;
this.y = y;
}
}
This works, but copying parameters into fields one line at a time is so common that Dart has a shortcut for it.
Initializing formals: this.x
Instead of the body above, write the parameter as this.fieldName and Dart assigns it for you — before the body even runs:
class Point {
double x;
double y;
Point(this.x, this.y); // no body needed at all
}
These this.-prefixed parameters are called initializing formals. They're the idiomatic way to write Dart constructors — you'll see them everywhere. The two versions above are equivalent, but this one is shorter and impossible to get wrong (no risk of forgetting this. and assigning a parameter to itself).
You can mix initializing formals with regular parameters and run extra logic in the body:
class Point {
double x;
double y;
Point(this.x, this.y) {
print('Created a point at ($x, $y)');
}
}
Named parameters in constructors
Positional arguments like Point(2, 3) are fine for two values, but once you have five, nobody remembers the order. Dart's named parameters (wrapped in {}) make calls self-documenting:
class Rect {
double width;
double height;
String color;
Rect({required this.width, required this.height, this.color = 'black'});
}
void main() {
var r = Rect(width: 120, height: 40, color: 'red');
var r2 = Rect(width: 80, height: 80); // color defaults to 'black'
}
Two things to notice:
requiredmeans the caller must provide that argument — leave it out and the code won't compile.this.color = 'black'gives a default value, making that argument optional.
This pattern is the backbone of Flutter — every widget constructor (Container(width: ..., color: ...)) is named parameters plus initializing formals. Get comfortable with it now.
Named constructors vs. named parameters — easy to confuse! Named parameters are the
{...}arguments we just saw. Named constructors are something different — that's next.
Named constructors
A class can have more than one constructor, each with a name, using the ClassName.identifier syntax. This lets you offer several clear ways to build the same type:
class Point {
double x;
double y;
Point(this.x, this.y); // the unnamed/default one
Point.origin() : x = 0, y = 0; // a named constructor
Point.diagonal(double v) : x = v, y = v;
}
void main() {
var a = Point(3, 4);
var b = Point.origin(); // (0, 0)
var c = Point.diagonal(5); // (5, 5)
}
Point.origin() reads like documentation at the call site — far clearer than Point(0, 0) with a comment. Named constructors are perfect for "common presets" and for alternative input formats, like building from JSON:
class Point {
double x;
double y;
Point(this.x, this.y);
Point.fromJson(Map<String, double> json)
: x = json['x']!,
y = json['y']!;
}
var p = Point.fromJson({'x': 1.0, 'y': 2.0});
That : x = ..., y = ... part after the colon is the next concept.
Initializer lists
Sometimes you need to compute or validate field values before the constructor body runs — for example, when fields are final and must be set exactly once. That's what the initializer list is for: the comma-separated assignments that come after a : and before the body.
class Temperature {
final double celsius;
final double fahrenheit;
Temperature.fromFahrenheit(this.fahrenheit)
: celsius = (fahrenheit - 32) * 5 / 9; // runs before the body
}
The initializer list runs first, in order, and cannot access this (the object isn't fully built yet) — but it can read the constructor's parameters. That's exactly enough to set up final fields from inputs.
You can also drop asserts into the initializer list to validate arguments:
class Person {
final String name;
final int age;
Person(this.name, this.age) : assert(age >= 0, 'Age cannot be negative');
}
If someone passes a negative age in debug mode, they get a clear failure right where the mistake happened.
Order of operations in a constructor: (1) initializing formals and the initializer list run, (2) the superclass constructor runs, (3) the constructor body runs. So your body can safely assume every field is already set.
Redirecting constructors
What if one constructor is just a special case of another? Don't duplicate the setup — redirect to the main constructor with : this(...). The redirecting constructor has no body and simply forwards its work:
class Point {
double x;
double y;
Point(this.x, this.y);
// Redirects to the main constructor with y fixed at 0.
Point.alongXAxis(double x) : this(x, 0);
}
Point.alongXAxis(7) quietly becomes Point(7, 0). This keeps a single source of truth for the real construction logic — if you later add validation to Point(this.x, this.y), every redirecting constructor benefits automatically.
const constructors (compile-time constants)
Here's a genuinely powerful Dart feature. If your object is fully immutable — every field is final — you can mark its constructor const. That lets callers create the object as a compile-time constant:
class ImmutablePoint {
final double x;
final double y;
const ImmutablePoint(this.x, this.y);
}
void main() {
const origin = ImmutablePoint(0, 0);
}
Why bother? Two real payoffs:
1. Canonicalization. Dart guarantees that two const objects with identical values are literally the same object in memory:
const a = ImmutablePoint(1, 2);
const b = ImmutablePoint(1, 2);
print(identical(a, b)); // true — one shared instance, not two
That saves memory and makes equality checks instant.
2. Flutter performance. A const widget is built once and reused, so Flutter can skip rebuilding it. This is why const widgets are the single easiest performance win in a Flutter app.
A few rules: a const constructor requires all fields to be final, and it can't have a body. To actually get a constant you must request one with const at the call site (const ImmutablePoint(1, 2)). Calling it without const just makes a normal, mutable-reference object.
Factory constructors
Every constructor we've seen so far is forced to create a brand-new instance. A factory constructor breaks that rule: it's a constructor that returns an object rather than implicitly building one. You add the factory keyword and return something yourself.
That unlocks three classic patterns:
1. Caching (return an existing instance)
class Logger {
final String name;
static final Map<String, Logger> _cache = {};
// Private named constructor — only this class can call it.
Logger._internal(this.name);
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
}
void main() {
var a = Logger('network');
var b = Logger('network');
print(identical(a, b)); // true — same cached instance
}
Ask for the 'network' logger twice and you get the same object back. A plain constructor could never do this, because it always makes something new.
2. Returning a subtype
A factory can decide which concrete class to hand back based on its input:
abstract class Shape {
factory Shape(String kind) {
return switch (kind) {
'circle' => Circle(),
'square' => Square(),
_ => throw ArgumentError('Unknown shape: $kind'),
};
}
}
The caller writes Shape('circle') and gets a Circle, never needing to know the concrete classes exist. (We'll explore abstract classes properly in Part 6.)
3. Parsing / validation that might fail to produce a "normal" object
class Fraction {
final int numerator;
final int denominator;
Fraction._(this.numerator, this.denominator);
factory Fraction(int n, int d) {
if (d == 0) throw ArgumentError('Denominator cannot be zero');
// could also normalize/simplify before constructing
return Fraction._(n, d);
}
}
The catch: because a factory returns an object rather than initializing
this, you cannot usethisinside it, and it can't appear in an initializer list of another constructor. It's a function that happens to produce an instance.
Super parameters (a peek ahead)
When one class extends another (Part 5), the child constructor must pass values up to the parent. The old way repeated every parameter; modern Dart forwards them with super.:
class Vector2d {
final double x, y;
Vector2d(this.x, this.y);
}
class Vector3d extends Vector2d {
final double z;
// `super.x` and `super.y` forward straight to Vector2d's constructor.
Vector3d(super.x, super.y, this.z);
}
We'll cover inheritance in depth in Part 5 — just recognize super.x as "this parameter feeds the parent's constructor."
Choosing the right constructor — a cheat sheet
| You want to… | Use… |
| ---------------------------------------------- | ----------------------------- |
| Set fields straight from arguments | initializing formals this.x |
| Offer self-explanatory call sites | named parameters {...} |
| Provide several named ways to build the type | named constructors X.foo() |
| Compute/validate final fields before the body| initializer list : f = ... |
| Make a convenience constructor reuse the main one | redirecting : this(...) |
| Build immutable, shareable, compile-time objects | const constructor |
| Cache, return a subtype, or decide what to return | factory constructor |
Practice Challenges
Challenge 1 — Named parameters. Write a User class with name (required) and isAdmin (defaults to false), using named parameters. Build an admin and a normal user.
Show solution
class User {
String name;
bool isAdmin;
User({required this.name, this.isAdmin = false});
}
void main() {
var admin = User(name: 'Root', isAdmin: true);
var normal = User(name: 'Sam'); // isAdmin defaults to false
}
required forces name; the default value on isAdmin makes it optional. This is the everyday Flutter constructor shape.
Challenge 2 — Named constructor. Add a User.guest() named constructor that creates a user named 'Guest' who is not an admin.
Show solution
class User {
String name;
bool isAdmin;
User({required this.name, this.isAdmin = false});
User.guest() : name = 'Guest', isAdmin = false;
}
var g = User.guest();
Because User.guest() takes no arguments, it sets the fields directly in the initializer list. Alternatively you could redirect: User.guest() : this(name: 'Guest');.
Challenge 3 — Initializer list + assert. Write a Circle class with a final double radius that rejects negative radii using an assert, and exposes the area.
Show solution
import 'dart:math';
class Circle {
final double radius;
Circle(this.radius) : assert(radius >= 0, 'radius must be non-negative');
double get area => pi * radius * radius;
}
The assert lives in the initializer list, so it runs before the body and catches bad input at the moment of construction (in debug mode). The get area is a computed property — Part 3 covers those in full.
Challenge 4 — const and canonicalization. Make a Color class with final red/green/blue ints and a const constructor. Create two identical colors and prove they're the same instance with identical.
Show solution
class Color {
final int r, g, b;
const Color(this.r, this.g, this.b);
}
void main() {
const a = Color(255, 0, 0);
const b = Color(255, 0, 0);
print(identical(a, b)); // true — const canonicalizes
}
All fields are final, so the constructor can be const. Two const Color(255, 0, 0) values collapse to one shared object. Drop the const at the call site and you'd get two separate (non-identical) instances.
Challenge 5 — Factory caching. Write a Currency class so that Currency('USD') returns the same object every time it's asked for a given code.
Show solution
class Currency {
final String code;
static final Map<String, Currency> _cache = {};
Currency._(this.code);
factory Currency(String code) =>
_cache.putIfAbsent(code, () => Currency._(code));
}
void main() {
print(identical(Currency('USD'), Currency('USD'))); // true
}
The private Currency._ constructor is the only real builder; the factory guards it with a cache, so repeated 'USD' lookups reuse one instance. This "interning" pattern is exactly what factory constructors were made for.
Challenge 6 — Factory returning a subtype. Write abstract class Notification with a factory Notification(String type) that returns an EmailNotification or SmsNotification based on the string.
Show solution
abstract class Notification {
void send();
factory Notification(String type) {
return switch (type) {
'email' => EmailNotification(),
'sms' => SmsNotification(),
_ => throw ArgumentError('Unknown type: $type'),
};
}
}
class EmailNotification implements Notification {
@override
void send() => print('Sending email...');
}
class SmsNotification implements Notification {
@override
void send() => print('Sending SMS...');
}
void main() {
Notification('email').send(); // Sending email...
Notification('sms').send(); // Sending SMS...
}
The factory acts as a tiny "factory pattern": callers ask for a Notification by name and get the right concrete type, without depending on those concrete classes directly. (implements and abstract classes are Part 6 — for now, note how the factory hides the decision.)
Questions to test yourself
Q1 (basic). What does Dart give you if you declare a class with no constructor at all?
Show answer
A free default constructor: unnamed, takes no arguments, and just creates the object. It disappears the moment you declare any constructor of your own.
Q2 (basic). What's the difference between named parameters and a named constructor?
Show answer
Named parameters are the {...} arguments of a single constructor (User({required this.name})) — they affect how you pass arguments. A named constructor is an additional, separately named constructor on the class (User.guest()) — it's a whole different way to build the object. A class can have many named constructors, and each can use named parameters.
Q3 (intermediate). Why can't an initializer list access this, and why is that fine for setting final fields?
Show answer
The initializer list runs before the object is fully constructed, so this isn't valid to reference yet. That's fine for final fields because the initializer list can read the constructor's parameters and assign each final field exactly once — which is all you need. By the time the body runs (where this is available), the fields are already set.
Q4 (intermediate). When would you reach for a redirecting constructor instead of just duplicating the field assignments?
Show answer
When one constructor is a special case of another and you want a single source of truth for the real construction logic. Point.alongXAxis(x) : this(x, 0) forwards to the main Point(x, y), so any validation or computation there is reused automatically — no duplication to keep in sync.
Q5 (advanced). Why can't a factory constructor use this, and how does that connect to what makes factories useful?
Show answer
A normal (generative) constructor initializes the specific new instance referred to by this. A factory doesn't promise to create a new instance at all — its job is to return an object, which might be a cached instance, a subtype, or something built elsewhere. Since there's no guaranteed "the new object being initialized," this has no meaning inside it. That very freedom — return whatever you like — is exactly what makes factories able to cache, pick subtypes, and validate.
Q6 (advanced). You mark a constructor const and all fields final, then write var p = ImmutablePoint(1, 2); (no const keyword). Is p a compile-time constant? Are two such ps identical?
Show answer
No to both. A const constructor only enables constness — you still have to request it at the call site with the const keyword. Writing var p = ImmutablePoint(1, 2) creates an ordinary runtime instance, so two of them are distinct objects and identical(...) is false. Only const ImmutablePoint(1, 2) gets canonicalized into a single shared compile-time constant.
Wrapping up
Constructors are Dart's superpower for object creation:
- Initializing formals (
this.x) are the idiomatic way to feed arguments into fields. - Named parameters (
{required ...}) make call sites readable — the foundation of Flutter widgets. - Named constructors (
X.foo()) offer multiple clear ways to build a type. - Initializer lists (
: f = ...) set upfinalfields and validate input before the body runs. - Redirecting (
: this(...)) keeps one source of truth. constconstructors give you immutable, canonicalized, performance-friendly objects.factoryconstructors return an object — enabling caching, subtypes, and validation.
In Part 3 we turn to the first pillar in depth: encapsulation — privacy, getters and setters, and static members — the art of hiding an object's internals behind a clean, safe surface.