Rating: 4.83 / 5 Based on 18 reviews
Unfortunately, the Dart team has taken up the very difficult decision of abandoning their work on the macros language feature.
<link to Dart team announcement post>
We were wholeheartedly cheering for this feature to come up eventually, but now that we know it won’t come, at least in the foreseeable future, we’re not caught off guard.
Macros would be the perfect solution for having proper data classes in Dart with little to no boilerplate.
A long time ago we noticed how much freezed code generation contributes to the time it takes for the build_runner to generate all the code, even with optimizations. Because of that, we migrated away from it to packages and solutions that also fulfilled our needs without big overhead.
Let’s take a look at the features that were most important to us in freezed:
One of the most valuable features in freezed was the union class “simulation”. We could create a class hierarchy that’s closed and strongly typed. It gave us the most value for state classes for our cubits and blocs. We most often had a hierarchy of a base SomethingState class with case classes SomethingInitial, SomethingLoadInProgress, SomethingLoadSuccess, and SomethingLoadFailure. Each had its own fields, sometimes all sharing a portion of them. In the base class, we had map, maybeMap, when, and maybeWhen that we used to return widgets appropriate to each state.
As Dart 3.0 was released, along with Flutter 3.10, sealed classes and pattern matching were introduced directly into the language. The class SomethingState extends _$SomethingState could be replaced with sealed class SomethingState. The freezed’s subclasses are now replaced with an ordinary subclass, marked final to close the hierarchy.
class SomethingState with _$SomethingState {
const factory SomethingState.initial() = SomethingInitial;
const factory SomethingState.loadInProgress() = SomethingLoadInProgress;
const factory SomethingState.loadSuccess() = SomethingLoadSuccess;
const factory SomethingState.loadFailure() = SomethingError;
}
sealed class SomethingState {
const SomethingState();
}
final class SomethingInitial extends SomethingState {
const SomethingInitial();
}
final class SomethingLoadInProgress extends SomethingState {
const SomethingLoadInProgress();
}
final class SomethingLoadSuccess extends SomethingState {
const SomethingLoadSuccess();
}
final class SomethingError extends SomethingState {
const SomethingError();
}
There are a few more lines to write (or to wait for Copilot to suggest), but in exchange, we don’t need to follow a specific pattern, otherwise making the code invalid for freezed generator, but rather we can write as many constructors, factories, subclasses, etc. as we need.
One of the things that freezed gave us, and we don’t have with sealed classes are automatic shared properties. Instead, we need to put the field in the base class.
Immutability in the Flutter world is crucial. But we don’t want our data classes to be compared by reference. That would mean that two instances with the exact same values would be considered not equal and could result in a state emission in cubit/bloc, or shouldRepaint, updateShouldNotify, and similar returning true resulting in widget rebuilds, and possible performance degradation or increased battery consumption.
In Dart, to override the equality behavior, we need to override the bool operator ==(Object other) and int get hashCode. The convention is to compare the type and the value of each field in equality operator override and use Object.hash/hashAll for hashCode. But that’s a little inconvenient, and we have two places to modify when adding a field.
For this, we use the equatable package. Each data class has with EquatableMixin (we don’t like Equatable class). equatable package overrides the equality operator and hash code using the fields we pass to the List<Object?> props getter, like this:
final class UserData with EquatableMixin {
const UserData({
required this.firstName,
required this.lastName,
required this.age,
});
final String firstName;
final String lastName;
final int age;
List<Object?> get props => [firstName, lastName, age];
}
Of course, we’re still only humans after all (don’t put your blame on me). We can forget to add a field to the props. But thankfully, there’s a Dart Code Metrics rule for that — list-all-equatable-fields.
copyWith
A copyWith method is a common pattern in the Dart ecosystem. We use it to create a new instance of a class with only one or a few fields changed, with the rest left the same.
Not all of our data classes need the copyWith. But the ones that do, we annotate with @CopyWith() from the copy_with_extension_gen package. This is the only code generator we use for the freezed functionality.
Although freezed is a very powerful package, it comes with some costs. At some point, we decided to move forward and use alternative solutions for new code and projects. Currently, the described approach is successfully used in the majority of our projects, and it significantly reduced the amount of our generated code.
Below is a comparison for an example state data class hierarchy from a production app both in freezed and using our three alternatives.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_flights_state.freezed.dart';
sealed class MyFlightsState with _$MyFlightsState {
const factory MyFlightsState.loaded({
required MyFlightsFilters filters,
(0) int currentPage,
(false) bool lastPage,
(LoadableStateLoading())
LoadableState<void, GetMyFlightsFailure> nextPageState,
([]) List<Flight> flights,
FlightsSummary? summary,
}) = MyFlightsLoadedState;
const factory MyFlightsState.error({
required MyFlightsFilters filters,
required GetMyFlightsFailure failure,
}) = MyFlightsErrorState;
}
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:equatable/equatable.dart';
part 'my_flights_state.g.dart';
sealed class MyFlightsState with EquatableMixin {
const MyFlightsState({
this.filters = const MyFlightsFilters(),
});
final MyFlightsFilters filters;
List<Object?> get props => [filters];
}
()
final class MyFlightsLoadedState extends MyFlightsState {
const MyFlightsLoadedState({
super.filters,
this.currentPage = 0,
this.lastPage = false,
this.nextPageState = const LoadableStateLoading(),
this.flights = const [],
this.summary,
});
final int currentPage;
final bool lastPage;
final LoadableState<void, GetMyFlightsFailure> nextPageState;
final List<Flight> flights;
final FlightsSummary? summary;
List<Object?> get props =>
[currentPage, lastPage, nextPageState, flights, summary, super.props];
}
final class MyFlightsErrorState extends MyFlightsState {
const MyFlightsErrorState({
super.filters,
required this.failure,
});
final GetMyFlightsFailure failure;
List<Object?> get props => [failure, super.props];
}
As you can see, the amount of code written by developers increased. But it’s almost all pure Dart with no hidden complexity (except for the copyWiths). The syntax for creating the classes is your ordinary Dart, with no special requirements for the generator.
This approach has worked for us for a few years now. It may also work for you. But you may also find another approach that works best, or even stay on freezed if that’s what you’re comfortable with. In the end, it’s important to choose the right tool for a job, there’s no golden pill.