Immutable Data & Value Objects
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);
}
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
}
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
}
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>.TextStyle, EdgeInsets, BoxDecoration, ThemeData -- all use the copyWith pattern. Mastering immutable objects in pure Dart directly prepares you for Flutter development.