Constructors are one of those topics I thought I understood after a week of Dart, and then completely rethought after six months of building real apps. The default constructor is easy. It’s everything after that — named constructors, factory constructors, redirecting constructors, constant constructors — where Dart quietly gives you tools that most other languages don’t offer in nearly as clean a form.
I want to walk through this the way I actually learned it: starting from the plain default constructor, working up through every variant, and then showing where each one earns its place in real code, including patterns I use constantly in Flutter.
Table of Contents
- What a Constructor Actually Does
- The Default Constructor
- Named Constructors
- Constructor Parameters: Positional, Named, and Initializing Formals
- The Initializer List
- Constant Constructors
- Redirecting Constructors
- Factory Constructors
- Factory Constructors for Singletons and Caching
- Factory Constructors in JSON Parsing (Real-World Flutter Use Case)
- Null Safety and Constructors
- Internal Working and Object Creation Mechanics
- Best Practices and Common Mistakes
- Debugging Constructor Issues
- FAQs
- Summary and References
1. What a Constructor Actually Does
A constructor is a special method whose job is to initialize an object’s fields the moment memory is allocated for it. In Dart, a constructor shares its name with the class (or a variant of it, for named constructors), and it doesn’t have a return type — it implicitly returns an instance of the class.
class User {
String name;
int age;
User(this.name, this.age);
}
void main() {
var user = User('Ahsan', 28);
print('${user.name} is ${user.age} years old');
}
Output:
Ahsan is 28 years old
That User(this.name, this.age) syntax is called an initializing formal — it assigns the constructor parameter directly to the instance field without needing a constructor body at all. I use this constantly because it eliminates so much boilerplate compared to Java or C#-style constructors.
2. The Default Constructor
If you don’t write any constructor at all, Dart generates a default, no-argument constructor for you automatically.
class Point {
double x = 0.0;
double y = 0.0;
}
void main() {
var p = Point();
print('(${p.x}, ${p.y})');
}
Output:
(0.0, 0.0)
The moment you define any constructor yourself — named or unnamed — Dart stops generating this implicit default constructor for you. This tripped me up early on: I added a named constructor to a class, forgot I no longer had a plain ClassName() constructor available, and got a compile error elsewhere in the codebase.
class Point {
double x, y;
Point.origin() : x = 0.0, y = 0.0;
}
void main() {
// var p = Point(); // Compile-time error: no matching constructor
var p = Point.origin();
print('(${p.x}, ${p.y})');
}
3. Named Constructors
This is one of my favorite Dart features, and something I genuinely miss when I write other languages. Named constructors let a class expose multiple, clearly labeled ways to construct an instance.
class Circle {
double radius;
Circle(this.radius);
Circle.unitCircle() : radius = 1.0;
Circle.fromDiameter(double diameter) : radius = diameter / 2;
}
void main() {
var c1 = Circle(5.0);
var c2 = Circle.unitCircle();
var c3 = Circle.fromDiameter(10.0);
print('c1 radius: ${c1.radius}');
print('c2 radius: ${c2.radius}');
print('c3 radius: ${c3.radius}');
}
Output:
c1 radius: 5.0
c2 radius: 1.0
c3 radius: 5.0
Instead of a single overloaded constructor trying to guess what you mean based on argument types (which Dart doesn’t support anyway — no constructor overloading by signature), named constructors make intent explicit right at the call site. Circle.fromDiameter(10.0) is self-documenting in a way that a generic overload never could be.
4. Constructor Parameters: Positional, Named, and Initializing Formals
Dart gives you three parameter styles, and I mix them depending on how many fields a class has and how readable I want the call site to be.
Positional Parameters
class Vector2D {
double x, y;
Vector2D(this.x, this.y);
}
Named Parameters
class Rectangle {
double width, height;
Rectangle({required this.width, required this.height});
}
void main() {
var r = Rectangle(width: 10, height: 5);
print('Area: ${r.width * r.height}');
}
Output:
Area: 50.0
I default to named parameters for any class with three or more fields, or any class where the field order isn’t obvious from context. Flutter itself leans heavily on this pattern — almost every widget constructor uses named parameters, which is why Container(width: 100, height: 50, color: Colors.blue) reads clearly without needing to memorize argument order.
Optional Positional Parameters
class Message {
String text;
String sender;
Message(this.text, [this.sender = 'Unknown']);
}
void main() {
var m1 = Message('Hello');
var m2 = Message('Hi', 'Ahsan');
print('${m1.sender}: ${m1.text}');
print('${m2.sender}: ${m2.text}');
}
Output:
Unknown: Hello
Ahsan: Hi
5. The Initializer List
The initializer list runs before the constructor body, and before the object is even considered fully constructed (specifically, before the superclass constructor runs). It’s the only place where you’re allowed to assign to final fields conditionally, and it’s where you’d put assert() validation.
class BankAccount {
final String owner;
final double balance;
BankAccount(this.owner, double initialDeposit)
: assert(initialDeposit >= 0, 'Deposit cannot be negative'),
balance = initialDeposit;
}
void main() {
var account = BankAccount('Ahsan', 500.0);
print('${account.owner} has \$${account.balance}');
try {
BankAccount('Sara', -100.0);
} catch (e) {
print('Error: $e');
}
}
Output:
Ahsan has $500.0
Error: Assertion failed: "Deposit cannot be negative"
Note: assert() only throws in debug/checked mode. In a release build, asserts are stripped out entirely for performance, so never rely on them for production-critical validation — use explicit if checks and throw for anything that must hold in release builds too.
6. Constant Constructors
If every field in a class is final, you can mark the constructor const, which enables compile-time constant instances. This is a performance feature as much as a correctness one.
class ImmutablePoint {
final double x, y;
const ImmutablePoint(this.x, this.y);
}
void main() {
const p1 = ImmutablePoint(1.0, 2.0);
const p2 = ImmutablePoint(1.0, 2.0);
print(identical(p1, p2)); // true — same canonical instance
}
Output:
true
This identical() result is the key insight: Dart’s compiler canonicalizes identical const expressions, meaning p1 and p2 literally point to the same object in memory rather than being two separate allocations with equal values. In Flutter, this is why const widgets are a real, measurable performance win — a const Text('Hello') doesn’t get rebuilt or reallocated when its parent rebuilds, because Flutter’s element tree can recognize it’s the exact same object as before.
7. Redirecting Constructors
A redirecting constructor forwards its work entirely to another constructor in the same class, avoiding duplicated initialization logic.
class Employee {
String name;
double salary;
Employee(this.name, this.salary);
Employee.intern(String name) : this(name, 0.0);
}
void main() {
var e = Employee.intern('Bilal');
print('${e.name} earns \$${e.salary}');
}
Output:
Bilal earns $0.0
A redirecting constructor’s body must be empty — all the work happens through the : this(...) redirect. I use this pattern whenever I have several named constructors that are really just variations on setting up the same underlying fields.
8. Factory Constructors
This is where things get genuinely powerful. A factory constructor is different from a normal constructor in one crucial way: it doesn’t have to create a new instance of the class. It can return an existing instance, an instance of a subclass, or run arbitrary logic before deciding what to construct.
class Logger {
final String name;
static final Map<String, Logger> _cache = {};
Logger._internal(this.name);
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
}
void main() {
var logger1 = Logger('network');
var logger2 = Logger('network');
var logger3 = Logger('database');
print(identical(logger1, logger2)); // true — same cached instance
print(identical(logger1, logger3)); // false — different instance
}
Output:
true
false
Notice Logger._internal — the underscore prefix makes it a private constructor, only callable within the same library file. This is the standard pattern: expose a public factory constructor as the “front door,” and hide the actual instance-creation logic behind a private constructor that only the factory can call.
Unlike a normal constructor, a factory constructor:
- Does not have direct access to
this - Cannot use an initializer list
- Must explicitly
returnan instance
9. Factory Constructors for Singletons and Caching
The singleton pattern is probably the most common real-world use of factory constructors that I run into.
class AppConfig {
static final AppConfig _instance = AppConfig._internal();
factory AppConfig() {
return _instance;
}
AppConfig._internal() {
print('AppConfig initialized');
}
String apiUrl = 'https://api.example.com';
}
void main() {
var config1 = AppConfig();
var config2 = AppConfig();
print(identical(config1, config2));
config1.apiUrl = 'https://staging.example.com';
print(config2.apiUrl); // Reflects the same change, same instance
}
Output:
AppConfig initialized
true
https://staging.example.com
Notice “AppConfig initialized” only prints once — the private constructor runs exactly once, at the point where the static final _instance field is lazily initialized on first access. Every subsequent AppConfig() call just returns that same cached reference.
10. Factory Constructors in JSON Parsing (Real-World Flutter Use Case)
This is, without exaggeration, the single most common place I use factory constructors in real Flutter apps: converting JSON (typically a Map<String, dynamic> from an API response) into a strongly typed Dart object.
class Product {
final String id;
final String name;
final double price;
final bool inStock;
Product({
required this.id,
required this.name,
required this.price,
required this.inStock,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
inStock: json['in_stock'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
'in_stock': inStock,
};
}
}
void main() {
var json = {
'id': 'p001',
'name': 'Wireless Mouse',
'price': 25,
'in_stock': true,
};
var product = Product.fromJson(json);
print('${product.name} costs \$${product.price}, in stock: ${product.inStock}');
print(product.toJson());
}
Output:
Wireless Mouse costs $25.0, in stock: true
{id: p001, name: Wireless Mouse, price: 25.0, in_stock: true}
Notice (json['price'] as num).toDouble() — API responses can send 25 as an int or 25.5 as a double depending on the backend, and casting through num first avoids a runtime type error if the JSON happens to send a whole number. This is a defensive habit that has saved me from production crashes more than once.
Factory Constructors Choosing Between Subclasses
Another powerful pattern: a factory constructor on a base class that returns different subclass instances depending on input.
abstract class Shape {
double area();
factory Shape.fromType(String type, double size) {
switch (type) {
case 'circle':
return Circle(size);
case 'square':
return Square(size);
default:
throw ArgumentError('Unknown shape type: $type');
}
}
}
class Circle implements Shape {
final double radius;
Circle(this.radius);
@override
double area() => 3.14159 * radius * radius;
}
class Square implements Shape {
final double side;
Square(this.side);
@override
double area() => side * side;
}
void main() {
var shapes = [Shape.fromType('circle', 4), Shape.fromType('square', 3)];
for (var s in shapes) {
print('${s.runtimeType} area: ${s.area()}');
}
}
Output:
Circle area: 50.26544
Square area: 9.0
11. Null Safety and Constructors
With sound null safety, Dart enforces that every non-nullable field must be initialized before the constructor finishes — no exceptions, checked at compile time.
class Task {
String title; // non-nullable, must be initialized
String? notes; // nullable, defaults to null automatically
Task(this.title, {this.notes});
}
void main() {
var t1 = Task('Buy groceries');
var t2 = Task('Write report', notes: 'Due Friday');
print('${t1.title} - notes: ${t1.notes}');
print('${t2.title} - notes: ${t2.notes}');
}
Output:
Buy groceries - notes: null
Write report - notes: Due Friday
If I try to declare String title; without initializing it in the constructor at all, Dart refuses to compile:
class Broken {
String title; // Error: non-nullable field must be initialized
Broken();
}
This compile-time enforcement is genuinely one of Dart’s best features — it eliminates an entire category of “forgot to set a required field” bugs that used to only show up at runtime in other languages.
required Keyword with Named Parameters
class Order {
final String customerName;
final List<String> items;
Order({required this.customerName, required this.items});
}
void main() {
var order = Order(customerName: 'Fatima', items: ['Book', 'Pen']);
print('${order.customerName} ordered ${order.items.length} items');
}
Output:
Fatima ordered 2 items
12. Internal Working and Object Creation Mechanics
Understanding what actually happens when you call a constructor helped me reason about performance and initialization order bugs. The sequence for a normal (non-factory) constructor is:
- Memory is allocated for the new object on the heap.
- Initializing formals (
this.field) run first. - The initializer list executes (assignments after the
:), in the order written. - The superclass constructor runs (implicitly
super()if not specified, or explicitly if you callsuper.named(...)). - The constructor body executes.
class Base {
Base() {
print('Base constructor');
}
}
class Derived extends Base {
int value;
Derived(this.value) : assert(value >= 0) {
print('Derived constructor, value = $value');
}
}
void main() {
Derived(5);
}
Output:
Base constructor
Derived constructor, value = 5
This order — superclass before subclass body — is why you can’t access this meaningfully in the initializer list (the object isn’t fully constructed yet), but you can freely use this inside the constructor body, since by that point the whole chain, including the superclass, has finished initializing.
A factory constructor works completely differently under the hood — it’s really just a static method that happens to use constructor call syntax. No implicit this, no automatic superclass chaining, no initializer list. That’s precisely why it’s free to return a cached or different instance: it was never bound to “always allocate new memory” in the first place.
13. Best Practices and Common Mistakes
- Prefer named constructors over boolean flag parameters.
Circle.unitCircle()is clearer thanCircle(1.0, isUnit: true). - Always pair a public factory with a private underlying constructor for singleton or caching patterns — don’t expose the raw constructor alongside the factory, or callers can bypass your caching logic entirely.
- Use
constconstructors wherever every field is genuinely immutable. In Flutter specifically, this has real, measurable rebuild-performance benefits. - Don’t overuse factory constructors for simple object creation. If you’re not doing caching, subclass selection, or complex validation, a plain constructor with initializing formals is simpler and clearer.
- Validate early, in the initializer list, using
assertfor development-time checks, but back it up with real runtime validation (if/throw) for anything that must be enforced in production. - Watch for the “lost default constructor” trap — once you add any constructor, Dart no longer generates the implicit no-argument one.
// Mistake: forgetting the underlying private constructor still allocates a NEW
// object each time if you don't actually cache it.
class BadSingleton {
factory BadSingleton() {
return BadSingleton._internal(); // Bug: creates a new instance every call!
}
BadSingleton._internal();
}
I’ve genuinely seen this bug in production code — a factory constructor that looks like a singleton but forgets to actually store and reuse a cached instance, silently creating a new object every single call.
14. Debugging Constructor Issues
- “The non-nullable field must be initialized” error: Add it to an initializing formal, give it a default value, or make it nullable with
?ifnullis a genuinely valid state. - Unexpected object identity in tests: If
identical(a, b)isfalsewhen you expectedtrue, check whether your constructor is actuallyconstand whether both call sites are using theconstkeyword — a missingconstat the call site creates a fresh instance even if the constructor supports canonicalization. - Factory constructor “not returning consistent type” errors: Make sure every code path in a
factoryconstructor returns an instance that’s compatible with the declared return type (the class or a subtype). - Initializer list order confusion: Remember that initializer list expressions execute in the order they’re written, left to right, which matters if one assignment depends on another.
15. FAQs
Q: Can a factory constructor be const? Yes — a const factory constructor is allowed, but only if it always returns the same compile-time constant. This is a well-known pattern for compile-time-safe singletons.
Q: Why can’t I use this in an initializer list beyond assignment? Because the object isn’t fully constructed yet — the superclass constructor hasn’t run, so calling instance methods on this at that point would be operating on a half-built object.
Q: Does Dart support constructor overloading like Java? No — Dart doesn’t allow multiple constructors with the same name but different parameter signatures. Named constructors are Dart’s answer to this, giving you multiple distinctly named creation paths instead.
Q: When should I use a factory constructor instead of a static method? Use a factory constructor when the “feels like construction” call-site syntax (ClassName.named(...)) genuinely represents creating or obtaining an instance of that class — it’s more discoverable in IDE autocomplete than an arbitrarily named static method, and it keeps the API consistent with other constructors.
Q: Can factory constructors be inherited? No, factory constructors, like all constructors, are not inherited by subclasses. Each subclass needs its own constructors.
16. Summary
Dart’s constructor system gives you far more expressive power than a single default constructor ever could. Named constructors turn ambiguous object creation into self-documenting call sites. Initializing formals and initializer lists cut boilerplate while still enforcing null-safety guarantees at compile time. Constant constructors unlock genuine memory and performance wins through canonicalization. And factory constructors break the “always allocate new memory” assumption entirely, enabling singletons, caching, subclass selection, and the JSON-parsing pattern that shows up in nearly every real Flutter app. Once these tools are second nature, designing a clean, safe, and efficient class API in Dart becomes almost effortless.
References
- Dart Language Tour — Constructors: https://dart.dev/language/constructors
- Dart Language Tour — Classes: https://dart.dev/language/classes
- Dart API Reference — dart:core: https://api.dart.dev/stable/dart-core/dart-core-library.html
- Effective Dart — Design Guidelines: https://dart.dev/effective-dart/design
- Effective Dart — Usage Guidelines: https://dart.dev/effective-dart/usage
- Dart Language Specification: https://dart.dev/guides/language/spec
- Flutter Documentation — Widgets and const constructors: https://docs.flutter.dev/perf/best-practices