Dart Object-Oriented Programming

Immutable Data & Value Objects

45 min Lesson 23 of 24

Why Immutability Matters

An immutable object is one whose state cannot change after it is created. Once you construct it, it stays the same forever. This seemingly simple idea has profound benefits for software quality:

  • Thread safety -- Immutable objects can be shared across isolates and async code without locks or race conditions.
  • Predictability -- If a function receives an immutable object, it cannot accidentally modify it. No side effects.
  • Easy caching -- Immutable objects with the same values are interchangeable, so they can be cached and reused.
  • State management -- Flutter’s setState, BLoC, Riverpod, and other patterns rely heavily on immutable state objects. Detecting change means comparing old vs new immutable objects.

In this lesson, you’ll master final vs const, the @immutable annotation, the copyWith pattern, and value objects -- all essential patterns for production Dart and Flutter code.

Final vs Const

Dart has two keywords for values that don’t change: final and const. They are related but different.

Final vs Const Explained

void main() {
  // final -- set once at runtime, cannot be reassigned
  final String name = 'Alice';
  // name = 'Bob';  // ERROR: final variable can only be set once

  final DateTime now = DateTime.now();  // OK: value determined at runtime
  final List<int> numbers = [1, 2, 3];
  numbers.add(4);  // OK! final means the variable cannot be reassigned,
                    // but the LIST ITSELF is still mutable

  // const -- compile-time constant, deeply immutable
  const double pi = 3.14159;
  const List<int> primes = [2, 3, 5, 7, 11];
  // primes.add(13);  // ERROR: cannot modify a const list

  // const requires compile-time values
  // const DateTime t = DateTime.now();  // ERROR: not a compile-time constant

  // const objects with same values are identical (same instance)
  const a = Point(1, 2);
  const b = Point(1, 2);
  print(identical(a, b));  // true -- same object in memory!
}

class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}
Key Distinction: final means “this variable can only be assigned once” but the object it holds can still be mutable. const means “this value is a compile-time constant and deeply immutable.” For truly immutable objects, all fields must be final and the constructor should be const.

Building Immutable Classes

An immutable class has only final fields, a const constructor, and no methods that modify state. Dart’s @immutable annotation (from package:meta or package:flutter) helps enforce this.

Immutable Class Pattern

// In Flutter, import 'package:flutter/foundation.dart' for @immutable
// In pure Dart, import 'package:meta/meta.dart'

class Money {
  final double amount;
  final String currency;

  const Money(this.amount, this.currency);

  // Operations return NEW objects -- never modify this one
  Money add(Money other) {
    if (currency != other.currency) {
      throw ArgumentError(
        'Cannot add $currency and ${other.currency}',
      );
    }
    return Money(amount + other.amount, currency);
  }

  Money subtract(Money other) {
    if (currency != other.currency) {
      throw ArgumentError(
        'Cannot subtract $currency and ${other.currency}',
      );
    }
    return Money(amount - other.amount, currency);
  }

  Money multiply(double factor) {
    return Money(amount * factor, currency);
  }

  @override
  bool operator ==(Object other) =>
      other is Money &&
      other.amount == amount &&
      other.currency == currency;

  @override
  int get hashCode => Object.hash(amount, currency);

  @override
  String toString() => '${amount.toStringAsFixed(2)} $currency';
}

void main() {
  const price = Money(29.99, 'USD');
  const tax = Money(2.40, 'USD');

  // add() returns a NEW Money object
  final total = price.add(tax);
  print(total);   // 32.39 USD
  print(price);   // 29.99 USD -- unchanged!

  // const objects with same values are identical
  const a = Money(10.00, 'USD');
  const b = Money(10.00, 'USD');
  print(a == b);           // true (equals)
  print(identical(a, b));  // true (same instance)
}

The copyWith Pattern

When you have an immutable object and need a slightly different version, use the copyWith pattern. This creates a new object with some fields changed and the rest copied from the original. This is the most important pattern in Flutter state management.

copyWith Pattern

class UserProfile {
  final String name;
  final String email;
  final int age;
  final String? avatarUrl;
  final bool isVerified;

  const UserProfile({
    required this.name,
    required this.email,
    required this.age,
    this.avatarUrl,
    this.isVerified = false,
  });

  // copyWith -- returns a new object with specified fields changed
  UserProfile copyWith({
    String? name,
    String? email,
    int? age,
    String? avatarUrl,
    bool? isVerified,
  }) {
    return UserProfile(
      name: name ?? this.name,
      email: email ?? this.email,
      age: age ?? this.age,
      avatarUrl: avatarUrl ?? this.avatarUrl,
      isVerified: isVerified ?? this.isVerified,
    );
  }

  @override
  String toString() =>
      'UserProfile(name: $name, email: $email, age: $age, '
      'avatar: $avatarUrl, verified: $isVerified)';

  @override
  bool operator ==(Object other) =>
      other is UserProfile &&
      other.name == name &&
      other.email == email &&
      other.age == age &&
      other.avatarUrl == avatarUrl &&
      other.isVerified == isVerified;

  @override
  int get hashCode => Object.hash(name, email, age, avatarUrl, isVerified);
}

void main() {
  const user = UserProfile(
    name: 'Alice',
    email: 'alice@example.com',
    age: 30,
  );

  // Change just the email -- everything else stays the same
  final updatedUser = user.copyWith(email: 'alice@newdomain.com');
  print(updatedUser);
  // UserProfile(name: Alice, email: alice@newdomain.com, age: 30, ...)

  // Original is unchanged
  print(user.email);  // alice@example.com

  // Chain multiple changes
  final verifiedUser = user
      .copyWith(isVerified: true)
      .copyWith(avatarUrl: 'https://example.com/alice.jpg');
  print(verifiedUser.isVerified);  // true
  print(verifiedUser.avatarUrl);   // https://example.com/alice.jpg
}
Tip: The copyWith pattern uses nullable parameters with ?? (null coalescing) to determine which fields to change. If a parameter is not provided (null), the original value is kept. This is the exact pattern Flutter uses for ThemeData.copyWith(), TextStyle.copyWith(), and many other framework classes.

Value Objects vs Entities

In domain-driven design, objects fall into two categories:

  • Value Objects -- Defined by their attributes. Two value objects with the same data are considered equal. Examples: Money(10, 'USD'), Color(255, 0, 0), Address('123 Main St').
  • Entities -- Defined by their identity. Two entities with the same data are NOT equal if they have different IDs. Examples: User(id: 1), Order(id: 'ORD-123').

Value Object vs Entity

// VALUE OBJECT -- equality based on attributes
class Address {
  final String street;
  final String city;
  final String zipCode;
  final String country;

  const Address({
    required this.street,
    required this.city,
    required this.zipCode,
    required this.country,
  });

  @override
  bool operator ==(Object other) =>
      other is Address &&
      other.street == street &&
      other.city == city &&
      other.zipCode == zipCode &&
      other.country == country;

  @override
  int get hashCode => Object.hash(street, city, zipCode, country);

  Address copyWith({
    String? street,
    String? city,
    String? zipCode,
    String? country,
  }) => Address(
    street: street ?? this.street,
    city: city ?? this.city,
    zipCode: zipCode ?? this.zipCode,
    country: country ?? this.country,
  );

  @override
  String toString() => '$street, $city $zipCode, $country';
}

// ENTITY -- equality based on identity (id)
class Customer {
  final String id;
  final String name;
  final Address address;

  const Customer({
    required this.id,
    required this.name,
    required this.address,
  });

  // Only compare by id -- not by name or address
  @override
  bool operator ==(Object other) =>
      other is Customer && other.id == id;

  @override
  int get hashCode => id.hashCode;

  Customer copyWith({
    String? name,
    Address? address,
  }) => Customer(
    id: id,  // id never changes
    name: name ?? this.name,
    address: address ?? this.address,
  );
}

void main() {
  // Value objects: same data = equal
  const addr1 = Address(street: '123 Main', city: 'NYC', zipCode: '10001', country: 'US');
  const addr2 = Address(street: '123 Main', city: 'NYC', zipCode: '10001', country: 'US');
  print(addr1 == addr2);  // true -- same address

  // Entities: same data, different id = NOT equal
  final cust1 = Customer(id: 'C001', name: 'Alice', address: addr1);
  final cust2 = Customer(id: 'C002', name: 'Alice', address: addr1);
  print(cust1 == cust2);  // false -- different customers

  // Update entity preserves identity
  final moved = cust1.copyWith(
    address: addr1.copyWith(city: 'Boston', zipCode: '02101'),
  );
  print(cust1 == moved);  // true -- same customer (same id)
}

Practical Example: Configuration Objects

Configuration objects are a perfect use case for immutability. They’re created once, passed around, and should never change unexpectedly. Here’s a complete example with nested immutable objects, validation, and the builder pattern.

Immutable Configuration System

class DatabaseConfig {
  final String host;
  final int port;
  final String database;
  final String? username;
  final String? password;
  final int maxConnections;
  final Duration connectionTimeout;

  const DatabaseConfig({
    required this.host,
    this.port = 5432,
    required this.database,
    this.username,
    this.password,
    this.maxConnections = 10,
    this.connectionTimeout = const Duration(seconds: 30),
  });

  // Named constructors for common configs
  const DatabaseConfig.localhost(String database)
      : this(host: 'localhost', database: database);

  factory DatabaseConfig.fromMap(Map<String, dynamic> map) {
    return DatabaseConfig(
      host: map['host'] as String,
      port: map['port'] as int? ?? 5432,
      database: map['database'] as String,
      username: map['username'] as String?,
      password: map['password'] as String?,
      maxConnections: map['maxConnections'] as int? ?? 10,
    );
  }

  String get connectionString =>
      'postgresql://${username != null ? "$username@" : ""}$host:$port/$database';

  DatabaseConfig copyWith({
    String? host,
    int? port,
    String? database,
    String? username,
    String? password,
    int? maxConnections,
    Duration? connectionTimeout,
  }) => DatabaseConfig(
    host: host ?? this.host,
    port: port ?? this.port,
    database: database ?? this.database,
    username: username ?? this.username,
    password: password ?? this.password,
    maxConnections: maxConnections ?? this.maxConnections,
    connectionTimeout: connectionTimeout ?? this.connectionTimeout,
  );
}

class AppConfig {
  final String appName;
  final String version;
  final bool debugMode;
  final DatabaseConfig database;
  final Duration sessionTimeout;

  const AppConfig({
    required this.appName,
    required this.version,
    this.debugMode = false,
    required this.database,
    this.sessionTimeout = const Duration(hours: 1),
  });

  // Convenience: create a development config
  factory AppConfig.development() => AppConfig(
    appName: 'MyApp (Dev)',
    version: '0.0.1-dev',
    debugMode: true,
    database: DatabaseConfig.localhost('myapp_dev'),
  );

  AppConfig copyWith({
    String? appName,
    String? version,
    bool? debugMode,
    DatabaseConfig? database,
    Duration? sessionTimeout,
  }) => AppConfig(
    appName: appName ?? this.appName,
    version: version ?? this.version,
    debugMode: debugMode ?? this.debugMode,
    database: database ?? this.database,
    sessionTimeout: sessionTimeout ?? this.sessionTimeout,
  );
}

void main() {
  // Create development config
  final devConfig = AppConfig.development();
  print(devConfig.database.connectionString);
  // postgresql://localhost:5432/myapp_dev

  // Derive production config from dev
  final prodConfig = devConfig.copyWith(
    appName: 'MyApp',
    version: '1.0.0',
    debugMode: false,
    database: devConfig.database.copyWith(
      host: 'db.production.com',
      database: 'myapp_prod',
      username: 'app_user',
      password: 'secret',
      maxConnections: 50,
    ),
  );
  print(prodConfig.database.connectionString);
  // postgresql://app_user@db.production.com:5432/myapp_prod

  // Original unchanged
  print(devConfig.debugMode);   // true
  print(prodConfig.debugMode);  // false
}
Warning: The copyWith pattern has a limitation: you cannot set a nullable field to null because null means “keep the original.” If you need to clear a nullable field, consider using a sentinel value or a wrapper like Optional<T>.
Flutter Connection: Nearly every widget property in Flutter is immutable. TextStyle, EdgeInsets, BoxDecoration, ThemeData -- all use the copyWith pattern. Mastering immutable objects in pure Dart directly prepares you for Flutter development.