Every Dart developer eventually writes this class:
class User {
final String name;
final String email;
final int age;
const User({required this.name, required this.email, required this.age});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
name == other.name &&
email == other.email &&
age == other.age;
@override
int get hashCode => name.hashCode ^ email.hashCode ^ age.hashCode;
@override
String toString() => 'User(name: $name, email: $email, age: $age)';
User copyWith({String? name, String? email, int? age}) {
return User(
name: name ?? this.name,
email: email ?? this.email,
age: age ?? this.age,
);
}
}Forty lines. Three fields. And every time you add a field, you update four places: the constructor, ==, hashCode, copyWith. Forget one — say, you add avatarUrl but don't update == — and you've introduced the kind of silent bug described in the Equatable article: two objects with different data that your app considers equal.
Freezed eliminates this entire class of mistake. You declare the fields once, run code generation, and get an immutable class with correct ==, hashCode, toString, copyWith, and optionally JSON serialization — all generated, all guaranteed to stay in sync.
Setting It Up
Freezed is a code generation package. It reads annotations in your source files and generates implementation code at build time. The setup requires three packages:
# pubspec.yaml
dependencies:
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1 # only if you need JSON serialization
dev_dependencies:
freezed: ^2.4.7
build_runner: ^2.4.8
json_serializable: ^6.7.1 # only if you need JSON serializationfreezed_annotation provides the annotations you write in your code — @freezed, @Default, etc. freezed is the code generator that reads those annotations and produces the implementation. build_runner is the Dart tool that orchestrates code generation. They're split this way so your production app only ships the lightweight annotations, not the generator itself.
After adding the packages:
dart pub getYour First Freezed Class
Here's the same User class, with Freezed:
// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
required int age,
}) = _User;
}Run code generation:
dart run build_runner buildThis produces user.freezed.dart — a generated file containing the full implementation: ==, hashCode, toString, copyWith, everything. The generated file is typically 80–120 lines for a three-field class. You never edit it. You never read it unless you're debugging the generator itself.
The part directive tells Dart that user.freezed.dart is part of this file — it shares the same library scope, which is why the generated code can access private members like _$User and _User.
That's it. Seven lines of source code, and you have a fully correct immutable data class. Add a field, re-run build_runner, and every method updates automatically.
The Anatomy of the Declaration
Every piece of the declaration exists for a reason. Understanding why prevents confusion when things don't compile.
@freezed // ← tells the generator to process this class
class User with _$User { // ← mixin provides ==, hashCode, toString, copyWith
const factory User({ // ← factory constructor, not a regular constructor
required String name,
required String email,
required int age,
}) = _User; // ← redirects to the generated implementation class
}`@freezed` is the annotation the code generator scans for. Without it, build_runner ignores the class entirely.
`with _$User` mixes in the generated code. The mixin _$User is defined in user.freezed.dart and provides ==, hashCode, toString, and copyWith. The underscore prefix means it's private to the library — which is why the part directive is required.
`const factory User(...) = _User` is a redirecting factory constructor. It says: "when someone calls User(name: 'Alice', ...), actually create an instance of _User." _User is the generated implementation class that stores the fields and is truly immutable. The const keyword means instances can be compile-time constants when all arguments are constants — const User(name: 'Alice', email: 'a@b.com', age: 30) is a single object created at compile time, not runtime.
Why a factory constructor? Because User itself is abstract in Freezed's model. The concrete class is _User. This separation is what makes union types possible — a single User type can have multiple concrete variants, each with different fields. We'll get to that.
copyWith: Immutable Updates Without the Pain
Immutability means you never modify an object — you create a new one with the changes you want. Without copyWith, that looks like this:
final user = User(name: 'Alice', email: 'alice@example.com', age: 30);
// Change just the email — manually rebuild everything:
final updated = User(name: user.name, email: 'newalice@example.com', age: user.age);Three fields is manageable. A class with twelve fields is not. And every time you add a field, you update every manual copy site.
Freezed generates copyWith automatically:
final updated = user.copyWith(email: 'newalice@example.com');
// User(name: Alice, email: newalice@example.com, age: 30)Only the fields you specify change. Everything else carries over. This is the standard immutable update pattern — React developers know it as spread syntax ({...user, email: 'new'}), Kotlin developers know it as copy().
Deep copyWith
Where Freezed's copyWith gets genuinely impressive is with nested objects. Consider:
@freezed
class Order with _$Order {
const factory Order({
required String id,
required User customer,
required Address shippingAddress,
}) = _Order;
}
@freezed
class Address with _$Address {
const factory Address({
required String street,
required String city,
required String country,
}) = _Address;
}To change the customer's city on an order — without Freezed:
final updated = order.copyWith(
shippingAddress: order.shippingAddress.copyWith(
city: 'Bucharest',
),
);With Freezed's deep copy:
final updated = order.copyWith.shippingAddress(city: 'Bucharest');One line. The generator creates chained copyWith accessors for every nested Freezed class. This is the kind of convenience that doesn't seem important until you have four levels of nesting and the manual version is fifteen lines of boilerplate.
Equality: Why Freezed Makes Equatable Obsolete
The Equatable article describes a specific bug: you add a field to your BLoC state class but forget to add it to the props list. Equatable sees the states as equal. BLoC swallows the emission. The UI doesn't rebuild.
Freezed eliminates this entirely. There is no props list. The generated == operator compares every field declared in the factory constructor, always, automatically. Add a field, re-run build_runner, and equality is correct.
@freezed
class SearchState with _$SearchState {
const factory SearchState({
required String query,
required List<Lead> results,
@Default(false) bool isLoading, // ← add this field
}) = _SearchState;
}Re-run build_runner. The generated == now includes isLoading. No props list to forget. No manual hashCode to update. The class is correct by construction.
This is not a small thing. In the Equatable model, correctness depends on the developer remembering to update a separate list every time the class changes. In the Freezed model, correctness is structural — the generated code derives directly from the factory constructor's parameters. It cannot go out of sync because there's only one source of truth.
For BLoC state classes specifically, this means you can add, remove, or rename fields with confidence that state equality will always reflect the actual data. The entire category of "BLoC doesn't emit because Equatable is stale" disappears.
Default Values
Not every field needs to be required. Freezed supports default values through the @Default annotation:
@freezed
class PaginationState with _$PaginationState {
const factory PaginationState({
required List<Lead> items,
@Default(1) int currentPage,
@Default(20) int pageSize,
@Default(false) bool isLoadingMore,
@Default(true) bool hasMorePages,
}) = _PaginationState;
}Why @Default(false) instead of just bool isLoading = false? Because Freezed's factory constructors use redirecting syntax (= _PaginationState), and Dart doesn't allow default values in redirecting factory constructors. The @Default annotation is Freezed's workaround — the generator reads it and produces the correct default in the generated implementation class.
Nullable fields without a default are implicitly null:
@freezed
class Profile with _$Profile {
const factory Profile({
required String name,
String? bio, // ← defaults to null, no @Default needed
String? avatarUrl,
}) = _Profile;
}Adding Custom Methods and Getters
Freezed classes can have custom methods. You need one extra line — a private empty constructor:
@freezed
class Temperature with _$Temperature {
const Temperature._(); // ← enables custom methods
const factory Temperature({
required double celsius,
}) = _Temperature;
double get fahrenheit => celsius * 9 / 5 + 32;
double get kelvin => celsius + 273.15;
bool get isFreezing => celsius <= 0;
bool get isBoiling => celsius >= 100;
String format() => '${celsius.toStringAsFixed(1)}°C';
}The private constructor const Temperature._() is needed because Freezed's generated mixin assumes the class has no generative constructors other than the factory. Adding this empty private constructor satisfies that requirement without affecting the external API.
Usage:
final temp = Temperature(celsius: 100);
print(temp.fahrenheit); // 212.0
print(temp.isBoiling); // true
print(temp.format()); // 100.0°CThe computed properties and methods are yours. The boilerplate — ==, hashCode, toString, copyWith — is generated. Each side handles what it's best at.
Union Types: Multiple States, One Type
This is where Freezed goes from "convenient" to "architecturally significant." Union types — also called sealed classes or algebraic data types — let you define a type that can be one of several variants, each with different fields.
The classic use case is BLoC state:
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = AuthInitial;
const factory AuthState.loading() = AuthLoading;
const factory AuthState.authenticated({
required User user,
required String token,
}) = AuthAuthenticated;
const factory AuthState.error({
required String message,
@Default(0) int retryCount,
}) = AuthError;
}One type — AuthState — with four variants. Each variant can have its own fields. AuthAuthenticated has user and token. AuthLoading has nothing. AuthError has message and retryCount. They're all AuthState, but they carry different data.
Without union types, you'd typically model this with a single class containing every possible field, most of them nullable:
// Without Freezed — the "everything nullable" approach:
class AuthState {
final bool isLoading;
final User? user;
final String? token;
final String? errorMessage;
final int retryCount;
// ...
}This compiles, but it allows invalid states. Nothing prevents isLoading: true with a non-null user. Nothing prevents errorMessage being set while token is also set. The type system doesn't enforce which combinations of fields are valid.
With Freezed's union types, invalid states are unrepresentable. AuthLoading cannot have a user. AuthAuthenticated cannot have an error message. The compiler enforces it.
Pattern Matching With when and map
Freezed generates exhaustive pattern matching methods. When you handle an AuthState, the compiler forces you to handle every variant:
// when — returns a value based on the variant
Widget build(BuildContext context) {
return state.when(
initial: () => const SplashScreen(),
loading: () => const LoadingSpinner(),
authenticated: (user, token) => HomeScreen(user: user),
error: (message, retryCount) => ErrorScreen(
message: message,
onRetry: retryCount < 3 ? () => bloc.add(RetryAuth()) : null,
),
);
}Every variant must be handled. If you add a fifth variant to AuthState and re-run build_runner, every when call in your codebase becomes a compile error until you handle the new case. The compiler won't let you forget.
map is similar but gives you the full variant object instead of destructured fields:
// map — receives the variant object
String describeState(AuthState state) {
return state.map(
initial: (_) => 'App starting',
loading: (_) => 'Authenticating...',
authenticated: (s) => 'Logged in as ${s.user.name}',
error: (s) => 'Error: ${s.message} (attempt ${s.retryCount})',
);
}There are also maybeWhen and maybeMap variants that provide a fallback orElse — useful when you only care about one or two variants:
// Only react to errors, ignore everything else
state.maybeWhen(
error: (message, _) => showSnackbar(message),
orElse: () {},
);The maybe variants sacrifice exhaustiveness for convenience. Use when/map when every variant matters (state-driven UI). Use maybeWhen/maybeMap when you're reacting to a specific variant (error handling, analytics).
Dart 3 Sealed Classes vs Freezed Unions
Dart 3 introduced native sealed classes and pattern matching. A reasonable question: does Freezed's union type feature still matter?
Here's the same AuthState with native Dart 3 syntax:
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
final String token;
AuthAuthenticated({required this.user, required this.token});
}
class AuthError extends AuthState {
final String message;
final int retryCount;
AuthError({required this.message, this.retryCount = 0});
}And pattern matching:
return switch (state) {
AuthInitial() => const SplashScreen(),
AuthLoading() => const LoadingSpinner(),
AuthAuthenticated(:final user) => HomeScreen(user: user),
AuthError(:final message, :final retryCount) => ErrorScreen(
message: message,
onRetry: retryCount < 3 ? () => bloc.add(RetryAuth()) : null,
),
};Native sealed classes give you exhaustive pattern matching — the compiler forces you to handle every subclass. That's the same guarantee Freezed provides.
What native sealed classes don't give you:
- `copyWith` — you write it yourself, per subclass, or you don't have it.
- `==` and `hashCode` — you write them yourself, per subclass, or you use Equatable (with all its pitfalls).
- `toString` — you write it yourself or get
Instance of 'AuthAuthenticated'. - Deep copy — no chained
copyWith.nested.field()syntax.
So the tradeoff is clear. If your sealed class variants are simple and rarely need copying or equality — enums with data, essentially — native Dart 3 is cleaner. No code generation, no part files, no build_runner.
If your variants carry data and participate in state management — where equality correctness is critical and copyWith is a daily need — Freezed still earns its place. The boilerplate it eliminates per variant class adds up fast.
Many projects use both: Freezed for state classes and data models, native sealed classes for simpler discriminated types.
JSON Serialization
Freezed integrates with json_serializable to generate fromJson and toJson methods. This connects directly to the [List\<dynamic\
- article](/blog/dart-list-dynamic-not-subtype-list-string-fix) — the generated
fromJsonhandles every cast correctly, soList<dynamic>never leaks into your domain layer.
Add the annotation and a fromJson factory:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart'; // ← json_serializable generates this
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
required int age,
@Default([]) List<String> tags,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}Run build_runner and you get both user.freezed.dart (immutability, equality, copyWith) and user.g.dart (JSON serialization). The generated fromJson in user.g.dart handles the List<String> cast properly — no manual List<String>.from() needed.
Usage:
// From JSON
final user = User.fromJson({'name': 'Alice', 'email': 'a@b.com', 'age': 30, 'tags': ['admin']});
// To JSON
final json = user.toJson();
// {'name': 'Alice', 'email': 'a@b.com', 'age': 30, 'tags': ['admin']}JSON With Union Types
Union types and JSON serialization together require Freezed to know which variant to deserialize into. By default, Freezed uses a runtimeType field:
@freezed
class ApiResponse with _$ApiResponse {
const factory ApiResponse.success({required List<Lead> data}) = ApiSuccess;
const factory ApiResponse.error({required String message}) = ApiError;
factory ApiResponse.fromJson(Map<String, dynamic> json) =>
_$ApiResponseFromJson(json);
}The JSON would include "runtimeType": "success" or "runtimeType": "error". If your API doesn't use this convention (most don't), you can customize the discriminator:
@Freezed(unionKey: 'type')
class ApiResponse with _$ApiResponse {
@FreezedUnionValue('ok')
const factory ApiResponse.success({required List<Lead> data}) = ApiSuccess;
@FreezedUnionValue('err')
const factory ApiResponse.error({required String message}) = ApiError;
factory ApiResponse.fromJson(Map<String, dynamic> json) =>
_$ApiResponseFromJson(json);
}Now Freezed looks for "type": "ok" or "type": "err" in the JSON to decide which variant to construct.
In practice, many teams don't use Freezed's union JSON serialization at all. Instead, they write a manual factory that inspects the JSON and calls the appropriate variant constructor:
factory ApiResponse.fromJson(Map<String, dynamic> json) {
if (json.containsKey('error')) {
return ApiResponse.error(message: json['error'] as String);
}
return ApiResponse.success(
data: (json['data'] as List).map((e) => Lead.fromJson(e)).toList(),
);
}This is more explicit and works with any API shape. The generated union deserialization is elegant but assumes control over the JSON format.
Custom JSON Field Names
When your API uses snake_case but your Dart code uses camelCase:
@freezed
class ClientLead with _$ClientLead {
const factory ClientLead({
required String id,
@JsonKey(name: 'full_name') required String fullName,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
}) = _ClientLead;
factory ClientLead.fromJson(Map<String, dynamic> json) =>
_$ClientLeadFromJson(json);
}@JsonKey is from json_annotation and works seamlessly with Freezed. The name parameter maps the JSON key to the Dart field. defaultValue provides a fallback when the key is missing from the JSON — different from @Default, which sets the Dart-side default for the constructor.
If your entire API uses snake_case, you can set it globally instead of per-field. In your build.yaml:
targets:
$default:
builders:
json_serializable:
options:
field_rename: snakeNow every json_serializable class uses snake_case mapping automatically. No @JsonKey(name: ...) needed unless a field deviates from the convention.
Freezed With BLoC: The Complete Pattern
Here's how all the pieces come together in a real BLoC feature. This is the pattern that eliminates the bugs from both the Equatable article and the forgotten await article simultaneously.
The state:
@freezed
class ClientLeadState with _$ClientLeadState {
const factory ClientLeadState.initial() = ClientLeadInitial;
const factory ClientLeadState.loading() = ClientLeadLoading;
const factory ClientLeadState.loaded({
required List<ClientLead> leads,
@Default(1) int currentPage,
@Default(true) bool hasMore,
}) = ClientLeadLoaded;
const factory ClientLeadState.error({
required String message,
}) = ClientLeadError;
}No Equatable. No props list. Equality is generated and always correct.
The event:
@freezed
class ClientLeadEvent with _$ClientLeadEvent {
const factory ClientLeadEvent.search({required String query}) = ClientLeadSearch;
const factory ClientLeadEvent.loadMore() = ClientLeadLoadMore;
const factory ClientLeadEvent.refresh() = ClientLeadRefresh;
}Events as union types too. This might seem like overkill — events are simpler than states — but it gives you exhaustive when matching in the BLoC, which means the compiler tells you when you add an event but forget to handle it.
The BLoC:
class ClientLeadBloc extends Bloc<ClientLeadEvent, ClientLeadState> {
final SearchClientLeadsUseCase _searchUseCase;
ClientLeadBloc(this._searchUseCase) : super(const ClientLeadState.initial()) {
on<ClientLeadSearch>(_onSearch);
on<ClientLeadLoadMore>(_onLoadMore);
on<ClientLeadRefresh>(_onRefresh);
}
String _currentQuery = '';
Future<void> _onSearch(
ClientLeadSearch event,
Emitter<ClientLeadState> emit,
) async {
_currentQuery = event.query;
emit(const ClientLeadState.loading());
final result = await _searchUseCase(event.query);
result.fold(
(failure) => emit(ClientLeadState.error(message: failure.message)),
(leads) => emit(ClientLeadState.loaded(leads: leads)),
);
}
Future<void> _onLoadMore(
ClientLeadLoadMore event,
Emitter<ClientLeadState> emit,
) async {
// Only load more if we're in the loaded state
final currentState = state;
if (currentState is! ClientLeadLoaded) return;
if (!currentState.hasMore) return;
final nextPage = currentState.currentPage + 1;
final result = await _searchUseCase(_currentQuery, page: nextPage);
result.fold(
(failure) => emit(ClientLeadState.error(message: failure.message)),
(newLeads) => emit(currentState.copyWith(
leads: [...currentState.leads, ...newLeads],
currentPage: nextPage,
hasMore: newLeads.isNotEmpty,
)),
);
}
}Notice the _onLoadMore handler. It checks state is ClientLeadLoaded, then uses currentState.copyWith to produce a new loaded state with the additional leads appended. Without Freezed, that copyWith would be manual — and the spread [...currentState.leads, ...newLeads] creates a new list (not a mutation), which is exactly what correct state emission requires, as the Equatable article explains.
The UI:
BlocBuilder<ClientLeadBloc, ClientLeadState>(
builder: (context, state) {
return state.when(
initial: () => const SearchPrompt(),
loading: () => const LoadingSpinner(),
loaded: (leads, currentPage, hasMore) => LeadList(
leads: leads,
hasMore: hasMore,
onLoadMore: () => bloc.add(const ClientLeadEvent.loadMore()),
),
error: (message) => ErrorDisplay(
message: message,
onRetry: () => bloc.add(ClientLeadEvent.search(query: _lastQuery)),
),
);
},
)Every state variant is handled. The compiler enforces it. The UI cannot accidentally render a loading spinner when data is available, or show an error while also showing results. The state union makes invalid UI states unrepresentable.
The build_runner Workflow
Code generation adds a step to your workflow. There are two ways to run it.
One-shot build — generates once and exits:
dart run build_runner build --delete-conflicting-outputsThe --delete-conflicting-outputs flag tells build_runner to overwrite existing generated files without prompting. Without it, you'll get a confirmation prompt every time a generated file changes, which gets tedious fast.
Watch mode — watches for file changes and regenerates automatically:
dart run build_runner watch --delete-conflicting-outputsWatch mode is what most developers run during development. Save a Freezed class, and the generated code updates within a few seconds. The initial run can be slow on large projects — 20–40 seconds — but incremental rebuilds are fast because build_runner only regenerates files whose inputs changed.
When generation fails, the error messages come from build_runner, not Dart. They can be cryptic. The most common causes:
- Missing
partdirective — you forgotpart 'filename.freezed.dart'; - Mismatched file names — the
partdirective's filename must match the source file - Invalid
@Defaultvalue — the default must be a const expression - Non-Freezed type in a Freezed union's JSON factory — nested types need their own
fromJson
When in doubt, run build_runner build (not watch) with --verbose to see the full error chain.
Should You Commit Generated Files?
Two schools of thought, both valid.
Commit them: CI doesn't need build_runner. Code review shows exactly what changed in the generated output. New developers can clone and run immediately. The downside: merge conflicts in generated files are noise, and diffs are bloated.
Don't commit them: Cleaner diffs, no merge conflicts in generated files. CI runs build_runner build as a step. The downside: slower CI, and everyone must run build_runner after pulling.
Most Flutter teams commit generated files. The merge conflict noise is real but manageable — you just re-run build_runner after resolving conflicts in the source files and commit the regenerated output.
Common Pitfalls
Forgetting to re-run build_runner
You add a field. Your IDE shows red squiggles because the generated copyWith doesn't include the new field yet. You need to re-run build_runner. In watch mode this happens automatically. In one-shot mode, you have to remember.
This is the most common friction point with Freezed, and it's real. The tradeoff is straightforward: an occasional build_runner run versus the entire category of Equatable bugs.
Mutable collections inside immutable classes
@freezed
class Dashboard with _$Dashboard {
const factory Dashboard({
required List<Widget> widgets, // ← this List is mutable
}) = _Dashboard;
}Freezed makes the Dashboard object immutable — you can't reassign widgets. But the List itself is still a regular Dart List. Code that has a reference to it can call .add() or .remove() and mutate it in place. Freezed doesn't deep-freeze collections.
The discipline: always create new collections when updating state, never mutate. [...state.widgets, newWidget] instead of state.widgets.add(newWidget). Freezed's generated == actually uses DeepCollectionEquality — so two objects with separate but content-identical lists are considered equal. But mutating a list in place means every object referencing it silently changes, which breaks the predictability that immutable state management depends on. Create new collections, not because equality would miss it, but because mutation is a shared-state bug waiting to happen.
For projects where this discipline is hard to enforce, consider package:fast_immutable_collections — it provides IList, IMap, and ISet types that are truly immutable and have value equality built in.
Private fields
Freezed factory constructors can't have private fields — _privateField in a factory parameter isn't valid Dart. If you need a field that's part of equality but not exposed in the public API, you'll need a different approach — typically a custom getter that derives from public fields, or a separate internal model.
When Not to Use Freezed
Freezed is not always the right tool. Some cases where it adds overhead without proportional value:
Entities with identity equality. If two User objects are "equal" when they have the same id regardless of other fields, Freezed's value equality — which compares every field — is wrong for your use case. You'd need to override == manually, which defeats the purpose. Use a plain class with a custom == based on id.
Classes that are genuinely mutable. Form state that changes field-by-field as the user types. Animation controllers. Builders. If the object's purpose is to be mutated in place, making it immutable and creating a new instance on every keystroke adds allocation pressure for no benefit.
Tiny internal types. A Pair<A, B> class used in one file doesn't need code generation. The boilerplate for three fields is manageable; for two fields it's trivial.
Projects that can't tolerate code generation. Some teams or CI environments don't support build_runner, or the build time is prohibitive on very large codebases. Native Dart 3 sealed classes plus Equatable is a reasonable Freezed-free alternative.
The decision framework: if the class participates in state management (BLoC, Riverpod, any reactive system), Freezed almost always pays for itself. If it's a data transfer object parsed from JSON, Freezed with json_serializable eliminates the List<dynamic> problem entirely. If it's anything else, evaluate whether the boilerplate it removes justifies the code generation dependency.
Freezed is a code generator. It writes the code you would have written — ==, hashCode, copyWith, toString — and guarantees that code stays in sync with your field declarations. That guarantee is what makes it architecturally significant. Not the convenience (though the convenience is real), but the fact that an entire category of state management bugs — the Equatable desync, the forgotten copyWith field, the mutated list that looks new but isn't — becomes structurally impossible.
The tradeoff is a build step and generated files in your project. For most Flutter applications, particularly those using BLoC or any reactive state management, that tradeoff is worth it on day one and pays compound interest every time you add a field to a state class without having to think about whether you updated props.