Constructors in Depth
Why Learn More About Constructors?
In the previous lesson, you learned how to create basic constructors with positional and named parameters. But Dart offers much more powerful constructor types that you will see everywhere in Flutter. Understanding them is essential because Flutter widgets use const constructors for performance, factory constructors for patterns like singletons, and named constructors for creating objects in different ways. This lesson covers all of them.
Quick Review: Default Constructor
The default constructor has the same name as the class and uses Dart’s shorthand this. syntax:
Default Constructor Review
class Point {
double x;
double y;
// Default constructor with positional parameters
Point(this.x, this.y);
}
void main() {
Point p = Point(3.0, 4.0);
print('(${p.x}, ${p.y})'); // (3.0, 4.0)
}
Initializer Lists
An initializer list runs before the constructor body. It is used to set final fields, perform validation with assert, or compute values from the parameters. Use a colon : after the parameter list.
Initializer List Basics
class Rectangle {
final double width;
final double height;
final double area;
// Initializer list computes area from width and height
Rectangle(this.width, this.height)
: area = width * height;
// With validation using assert (only runs in debug mode)
Rectangle.validated(this.width, this.height)
: assert(width > 0, 'Width must be positive'),
assert(height > 0, 'Height must be positive'),
area = width * height;
@override
String toString() => 'Rectangle(${width}x$height, area: $area)';
}
void main() {
var r1 = Rectangle(10, 5);
print(r1); // Rectangle(10.0x5.0, area: 50.0)
var r2 = Rectangle.validated(8, 3);
print(r2); // Rectangle(8.0x3.0, area: 24.0)
// This would fail the assert in debug mode:
// var r3 = Rectangle.validated(-5, 3); // AssertionError!
}
this in the initializer list (except in the shorthand this.field parameters). Initializer lists are the only way to initialize final fields that are computed from parameters.Named Constructors
Dart does not support constructor overloading (multiple constructors with different parameters). Instead, it uses named constructors to provide alternative ways to create an object. The syntax is ClassName.constructorName().
Named Constructors
class Point {
double x;
double y;
// Default constructor
Point(this.x, this.y);
// Named constructor: create from origin
Point.origin()
: x = 0,
y = 0;
// Named constructor: create on the X axis
Point.onXAxis(double x)
: this.x = x,
y = 0;
// Named constructor: create on the Y axis
Point.onYAxis(double y)
: x = 0,
this.y = y;
// Named constructor: create from a Map
Point.fromMap(Map<String, double> map)
: x = map['x'] ?? 0,
y = map['y'] ?? 0;
double get distanceFromOrigin =>
(x * x + y * y).toDouble();
@override
String toString() => 'Point($x, $y)';
}
void main() {
var p1 = Point(3, 4);
var p2 = Point.origin();
var p3 = Point.onXAxis(5);
var p4 = Point.onYAxis(7);
var p5 = Point.fromMap({'x': 1, 'y': 2});
print(p1); // Point(3.0, 4.0)
print(p2); // Point(0.0, 0.0)
print(p3); // Point(5.0, 0.0)
print(p4); // Point(0.0, 7.0)
print(p5); // Point(1.0, 2.0)
}
Real-World Example: User Model
class User {
final String name;
final String email;
final String role;
final DateTime createdAt;
User({
required this.name,
required this.email,
this.role = 'user',
}) : createdAt = DateTime.now();
// Named constructor: create an admin
User.admin({required String name, required String email})
: this.name = name,
this.email = email,
role = 'admin',
createdAt = DateTime.now();
// Named constructor: create a guest
User.guest()
: name = 'Guest',
email = 'guest@example.com',
role = 'guest',
createdAt = DateTime.now();
// Named constructor: create from JSON map
User.fromJson(Map<String, dynamic> json)
: name = json['name'] as String,
email = json['email'] as String,
role = json['role'] as String? ?? 'user',
createdAt = DateTime.parse(json['created_at'] as String);
// Convert to JSON map
Map<String, dynamic> toJson() => {
'name': name,
'email': email,
'role': role,
'created_at': createdAt.toIso8601String(),
};
@override
String toString() => 'User($name, $email, $role)';
}
void main() {
var user = User(name: 'Ahmed', email: 'ahmed@test.com');
var admin = User.admin(name: 'Sara', email: 'sara@test.com');
var guest = User.guest();
print(user); // User(Ahmed, ahmed@test.com, user)
print(admin); // User(Sara, sara@test.com, admin)
print(guest); // User(Guest, guest@example.com, guest)
// Simulate JSON from an API
var json = {'name': 'Khalid', 'email': 'k@test.com', 'role': 'editor', 'created_at': '2024-01-15T10:30:00.000'};
var fromApi = User.fromJson(json);
print(fromApi); // User(Khalid, k@test.com, editor)
}
.fromJson() and methods like .toJson() are the standard pattern in Flutter for converting between Dart objects and JSON data from APIs. You will use this pattern in almost every Flutter app.Redirecting Constructors
A redirecting constructor delegates to another constructor in the same class. It cannot have a body -- it simply calls the target constructor using : this(...).
Redirecting Constructors
class Color {
final int red;
final int green;
final int blue;
final double opacity;
// Primary constructor
Color(this.red, this.green, this.blue, {this.opacity = 1.0});
// Redirecting constructors -- delegate to the primary
Color.red() : this(255, 0, 0);
Color.green() : this(0, 255, 0);
Color.blue() : this(0, 0, 255);
Color.white() : this(255, 255, 255);
Color.black() : this(0, 0, 0);
// Redirect with custom opacity
Color.semiTransparent(int r, int g, int b)
: this(r, g, b, opacity: 0.5);
@override
String toString() =>
'Color($red, $green, $blue, opacity: $opacity)';
}
void main() {
print(Color.red()); // Color(255, 0, 0, opacity: 1.0)
print(Color.blue()); // Color(0, 0, 255, opacity: 1.0)
print(Color.semiTransparent(100, 200, 50));
// Color(100, 200, 50, opacity: 0.5)
}
Const Constructors
A const constructor creates compile-time constant objects. When you create two const objects with the same values, Dart reuses the same instance in memory. This is a huge performance optimization in Flutter, where widgets are rebuilt frequently.
Const Constructor Basics
class ImmutablePoint {
final double x;
final double y;
// Const constructor -- all fields must be final
const ImmutablePoint(this.x, this.y);
// Named const constructor
const ImmutablePoint.origin() : x = 0, y = 0;
@override
String toString() => 'ImmutablePoint($x, $y)';
}
void main() {
// Using const -- same values produce the SAME object
const p1 = ImmutablePoint(1, 2);
const p2 = ImmutablePoint(1, 2);
print(identical(p1, p2)); // true! They are the SAME object
// Without const -- different objects even with same values
var p3 = ImmutablePoint(1, 2);
var p4 = ImmutablePoint(1, 2);
print(identical(p3, p4)); // false! Different objects
// Const named constructor
const origin = ImmutablePoint.origin();
print(origin); // ImmutablePoint(0.0, 0.0)
}
• All instance fields must be
final.• The constructor body must be empty (no code inside
{}).• You can use initializer lists, but only with constant expressions.
• The class cannot have any mutable (non-final) fields.
Const in Flutter Context
// This is what Flutter widgets look like
class AppConfig {
final String appName;
final String version;
final bool debugMode;
const AppConfig({
required this.appName,
required this.version,
this.debugMode = false,
});
}
void main() {
// const objects are created at compile time -- zero runtime cost
const config = AppConfig(
appName: 'My Flutter App',
version: '1.0.0',
);
print(config.appName); // My Flutter App
// In Flutter, const widgets skip rebuilds:
// const Text('Hello') -- never rebuilt!
// Text('Hello') -- rebuilt every time parent rebuilds
}
const constructors when possible in Flutter. A const Text('Hello') widget is created once at compile time and never rebuilt, while a non-const Text('Hello') is recreated every time the parent widget rebuilds. This small difference can have a big impact on app performance.Factory Constructors
A factory constructor is a special constructor that does not always create a new instance. Unlike normal constructors, it can:
- Return an existing instance (caching/singleton pattern)
- Return a subclass instance
- Run logic before deciding what to return
- Return
null(if the return type is nullable)
Factory Constructor: Singleton Pattern
class Database {
static Database? _instance;
final String connectionString;
// Private constructor -- cannot be called from outside
Database._internal(this.connectionString);
// Factory always returns the same instance
factory Database(String connectionString) {
_instance ??= Database._internal(connectionString);
return _instance!;
}
void query(String sql) {
print('Running: $sql on $connectionString');
}
}
void main() {
var db1 = Database('localhost:3306/mydb');
var db2 = Database('other:5432/other'); // Ignored! Returns same instance
print(identical(db1, db2)); // true -- same object
db1.query('SELECT * FROM users');
// Running: SELECT * FROM users on localhost:3306/mydb
}
Factory Constructor: Caching
class Logger {
static final Map<String, Logger> _cache = {};
final String name;
// Private constructor
Logger._internal(this.name);
// Factory returns cached instance or creates new one
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
void log(String message) {
print('[$name] $message');
}
}
void main() {
var appLogger = Logger('App');
var dbLogger = Logger('Database');
var appLogger2 = Logger('App'); // Returns cached instance
print(identical(appLogger, appLogger2)); // true
print(identical(appLogger, dbLogger)); // false
appLogger.log('Started'); // [App] Started
dbLogger.log('Connected'); // [Database] Connected
}
Factory Constructor: Return Subtype
abstract class Shape {
double get area;
// Factory that returns different subtypes
factory Shape.circle(double radius) = Circle;
factory Shape.square(double side) = Square;
}
class Circle implements Shape {
final double radius;
Circle(this.radius);
@override
double get area => 3.14159 * radius * radius;
@override
String toString() => 'Circle(r=$radius, area=${area.toStringAsFixed(2)})';
}
class Square implements Shape {
final double side;
Square(this.side);
@override
double get area => side * side;
@override
String toString() => 'Square(s=$side, area=${area.toStringAsFixed(2)})';
}
void main() {
Shape s1 = Shape.circle(5);
Shape s2 = Shape.square(4);
print(s1); // Circle(r=5.0, area=78.54)
print(s2); // Square(s=4.0, area=16.00)
}
Factory vs Named vs Const: When to Use What
• Default constructor -- Standard object creation.
• Named constructor -- Alternative ways to create an object (e.g.,
.fromJson(), .empty()).• Const constructor -- Immutable objects for performance (all fields must be
final).• Factory constructor -- When you need control: caching, singletons, returning subtypes, or conditional logic.
• Redirecting constructor -- Shorthand that delegates to another constructor in the same class.
Practical Example: Configuration System
Complete Example Using Multiple Constructor Types
class AppTheme {
final String name;
final int primaryColor;
final int backgroundColor;
final double fontSize;
// Default const constructor
const AppTheme({
required this.name,
required this.primaryColor,
required this.backgroundColor,
this.fontSize = 16.0,
});
// Named const constructors for presets
const AppTheme.light()
: name = 'Light',
primaryColor = 0xFF2196F3,
backgroundColor = 0xFFFFFFFF,
fontSize = 16.0;
const AppTheme.dark()
: name = 'Dark',
primaryColor = 0xFF64B5F6,
backgroundColor = 0xFF121212,
fontSize = 16.0;
// Named constructor from JSON (non-const, since it computes values)
AppTheme.fromJson(Map<String, dynamic> json)
: name = json['name'] as String,
primaryColor = json['primary_color'] as int,
backgroundColor = json['background_color'] as int,
fontSize = (json['font_size'] as num?)?.toDouble() ?? 16.0;
// Factory for caching themes by name
static final Map<String, AppTheme> _cache = {};
factory AppTheme.cached(String name) {
return _cache.putIfAbsent(name, () {
return switch (name) {
'light' => const AppTheme.light(),
'dark' => const AppTheme.dark(),
_ => const AppTheme.light(),
};
});
}
Map<String, dynamic> toJson() => {
'name': name,
'primary_color': primaryColor,
'background_color': backgroundColor,
'font_size': fontSize,
};
@override
String toString() => 'AppTheme($name, fontSize: $fontSize)';
}
void main() {
// Const constructors
const light = AppTheme.light();
const dark = AppTheme.dark();
print(light); // AppTheme(Light, fontSize: 16.0)
print(dark); // AppTheme(Dark, fontSize: 16.0)
// Const objects with same values are identical
const light2 = AppTheme.light();
print(identical(light, light2)); // true
// Custom theme
const custom = AppTheme(
name: 'Ocean',
primaryColor: 0xFF006064,
backgroundColor: 0xFFE0F7FA,
fontSize: 18.0,
);
print(custom); // AppTheme(Ocean, fontSize: 18.0)
// From JSON (like API response)
var fromApi = AppTheme.fromJson({
'name': 'Brand',
'primary_color': 0xFFFF5722,
'background_color': 0xFFFFF3E0,
});
print(fromApi); // AppTheme(Brand, fontSize: 16.0)
// Cached factory
var t1 = AppTheme.cached('dark');
var t2 = AppTheme.cached('dark');
print(identical(t1, t2)); // true
}
Practice Exercise
Open DartPad and build the following: (1) Create a Temperature class with a private _celsius field. (2) Add a default constructor that takes celsius. (3) Add a named constructor Temperature.fromFahrenheit(double f) that converts to celsius using the initializer list. (4) Add a named constructor Temperature.fromJson(Map). (5) Add a const constructor Temperature.absoluteZero() for -273.15. (6) Add a factory constructor Temperature.boiling() that returns a cached instance at 100°C. (7) Add getters for celsius, fahrenheit, and kelvin. (8) Test all constructors and print the values in all three scales.