Newsletter
Subscribe if you want to know how to build digital products
By submitting your email you agree to receive the content requested and to LeanCode's Privacy Policy

No Macros in Dart, How to Replace Freezed?

Albert Wolszon
Senior Flutter Developer at LeanCode, Flutter & Dart GDE

Rating: 4.83 / 5 Based on 18 reviews

Jan 29, 2025 • 5 min

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:

  1. Union classes — to have a closed set of data structures with different fields that we can map over.
  2. Value equality — instead of reference equality.
  3. copyWith — to conveniently create a new instance of the immutable data class with only some fields changed.

Union classes

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.


Before


class SomethingState with _$SomethingState {
 const factory SomethingState.initial() = SomethingInitial;
 const factory SomethingState.loadInProgress() = SomethingLoadInProgress;
 const factory SomethingState.loadSuccess() = SomethingLoadSuccess;
 const factory SomethingState.loadFailure() = SomethingError;
}

After

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.

Value equality

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.


Summary on how to replace freezed

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.

Before

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;
}

After

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.

Rate this article
4.83 / 5 Based on 18 reviews

Read more

How We Boosted Moving Flutter Widgets to Widgetbook

At LeanCode, while working on complex Flutter projects, we noticed that integrating widgets into Widgetbook was often repetitive and time-consuming. To address this, we created the Widgetbook Entries Generator - a VSCode extension that simplifies the process. See how it works.
Widgetbook Entries Generator by Leancode

Flutter Conferences 2025: Must-Attend Events for Flutter Devs!

At LeanCode, we love live events. As co-founders of Flutter Europe and founder of Flutter Warsaw, we take every opportunity to visit our friends at other events of this kind. This time, we're diving into the most exciting conferences tailored specifically for Flutter enthusiasts in 2025. As a result, you can grab this curated list of Flutter conferences.
List of Flutter Conferences 2025

Feature-Based Flutter App Architecture - LeanCode

Read about the LeanCode approach to Flutter architecture. We highlight some of the design decisions that should be made when developing a feature in mobile apps. This includes our approach to dependency injection, state management, widget lifecycle, and data fetching.
Flutter architecture by LeanCode