Immutable data classes in Flutter with freezed
By John · 24 February 2026
Freezed generates immutable data classes with copyWith, pattern matching, unions, and sealed classes — eliminating boilerplate that would otherwise be hundreds of lines of manual code.
Setup
dependencies:
freezed_annotation: ^2.4.1
dev_dependencies:
freezed: ^2.4.5
build_runner: ^2.4.9
Basic data class
import 'package:freezed_annotation/freezed_annotation.dart';
part 'order.freezed.dart';
part 'order.g.dart'; // For JSON, add json_serializable
@freezed
class Order with _$Order {
const factory Order({
required String id,
required String customerId,
required double total,
required OrderStatus status,
required DateTime createdAt,
String? notes,
}) = _Order;
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
}
Generate:
flutter pub run build_runner build --delete-conflicting-outputs
You get for free:
- Immutability (all fields are final)
copyWithfor updating specific fields- Structural equality (
==andhashCode) toString- JSON serialization (with
json_serializable)
final order = Order(
id: 'order-1',
customerId: 'cust-1',
total: 99.99,
status: OrderStatus.pending,
createdAt: DateTime.now(),
);
// Update specific fields
final shipped = order.copyWith(status: OrderStatus.shipped);
// Equality
order == order.copyWith(); // true
order == shipped; // false
// JSON
final json = order.toJson();
final restored = Order.fromJson(json);
Sealed classes (unions)
Freezed excels at sealed class hierarchies, perfect for state machines:
@freezed
class OrderState with _$OrderState {
const factory OrderState.initial() = _Initial;
const factory OrderState.loading() = _Loading;
const factory OrderState.loaded({
required List<Order> orders,
required bool hasMore,
}) = _Loaded;
const factory OrderState.error(String message) = _Error;
}
Pattern match with when or map:
state.when(
initial: () => const SizedBox.shrink(),
loading: () => const CircularProgressIndicator(),
loaded: (orders, hasMore) => OrdersList(orders: orders),
error: (message) => ErrorView(message: message),
);
// maybeWhen — handle specific cases, provide default for others
state.maybeWhen(
error: (message) => showSnackbar(message),
orElse: () {},
);
// map — exhaustive switch with full access to subtype fields
state.map(
initial: (_) => const SizedBox.shrink(),
loading: (_) => const CircularProgressIndicator(),
loaded: (s) => OrdersList(orders: s.orders, hasMore: s.hasMore),
error: (s) => ErrorView(message: s.message),
);
Adding methods to frozen classes
You can add methods to frozen classes using the const MyClass._() trick:
@freezed
class Order with _$Order {
const Order._(); // Enables methods on frozen class
const factory Order({...}) = _Order;
bool get isPending => status == OrderStatus.pending;
bool get isComplete => status == OrderStatus.delivered || status == OrderStatus.cancelled;
String get formattedTotal => NumberFormat.currency(symbol: '\$').format(total);
}
Deep copyWith
For nested frozen objects:
@freezed
class UserProfile with _$UserProfile {
const factory UserProfile({
required String name,
required Address address,
}) = _UserProfile;
}
@freezed
class Address with _$Address {
const factory Address({
required String street,
required String city,
required String postcode,
}) = _Address;
}
// Deep update using copyWith chaining
final updated = profile.copyWith(
address: profile.address.copyWith(city: 'London'),
);
Nullable fields and defaults
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
@Default(0.0) double rating, // Default value
@Default([]) List<String> tags, // Default empty list
String? description, // Nullable, defaults to null
}) = _Product;
}
Custom JSON serialization
@freezed
class Order with _$Order {
const factory Order({
required String id,
@JsonKey(name: 'customer_id') required String customerId, // Snake case in JSON
@JsonKey(toJson: _statusToJson, fromJson: _statusFromJson)
required OrderStatus status,
}) = _Order;
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
static String _statusToJson(OrderStatus s) => s.name;
static OrderStatus _statusFromJson(String s) => OrderStatus.values.byName(s);
}
Common pitfalls
Forgetting part directives. Freezed generates code in separate files. You need both part 'order.freezed.dart' and (if using JSON) part 'order.g.dart'. Missing either causes compile errors.
Mutable lists in frozen classes. @Default([]) List<String> tags creates a mutable list. Use const [] or UnmodifiableListView if you need true immutability for lists.
Adding a required field to an existing class. If you have Order instances in Hive or Firestore with the old schema, adding a required field breaks deserialization. Make new fields nullable or provide a default.
Sign in to like, dislike, or report.