Dart Object-Oriented Programming

Constructors in Depth

45 min Lesson 2 of 8

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!
}
Note: The initializer list runs before the constructor body. This means you cannot use 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)
}
Flutter Pattern: Named constructors like .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)
}
Rules for const constructors:
• 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
}
Flutter Performance Tip: Always use 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

Quick Guide:
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.