Code generation will take your Flutter app development process to the next level. You’ll learn how to use freezed to generate data classes and unions to greatly reduce the amount of boilerplate code in your apps.
The Normal Way
Here we have User
class with a String name
and int age
.
class User {
const User({
required this.name,
required this.age,
});
final String name;
final int age;
}
If we wanted to compare two Users
, we’d have to override the ==
operator and hashcode
method, which gets pretty tedious to write as we add more fields to our class.
class User {
// ...
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
name == other.name &&
age == other.age;
@override
int get hashCode => name.hashCode ^ age.hashCode;
}
Equatable
That’s where the equatable package comes in. Let’s add equatable to our pubspec.yaml
.
# pubspec.yaml
dependencies:
equatable: ^2.0.5 # or <latest_version>
Now we only have to extend Equatable
and define a props
getter to automatically override ==
and hashcode
. And because our class is immutable, or cannot be changed, we have to add a copyWith
method to easily modify values by creating a new instance of our class. Note that this copyWith
doesn’t support assigning null
to values because of the null-aware (??
) operator.
// user_model.dart
class User extends Equatable {
// ...
@override
List<Object?> get props => [name, age];
User copyWith({
String? name,
int? age,
}) {
return User(
name: name ?? this.name,
age: age ?? this.age,
);
}
}
When copyWith
is called, you can see only the age modified in copyWith
is changed, the Name stayed the same.
// main.dart
void main() {
const userA = User(name: 'User A', age: 100);
print(userA); // User(name: User A, age: 100)
final userCopy = userA.copyWith(age: 50)
print(userCopy); // User(name: User A, age: 50)
}
Finally, our User class might have toJson
and fromJson
methods, converting our class into Map<String, dynamic>
and back into a User
respectively.
// user_model.dart
class User extends Equatable {
// ...
Map<String, dynamic> toJson() {
return {
'name': name,
'age': age,
};
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'] ?? '',
age: json['age'] ?? 0,
);
}
}
void main() {
const userA = User(name: 'User A', age: 100);
print(userA); // User(name: User A, age: 100)
final json = userA.toJson();
print(json); // {name: User A, age: 100}
final userFromJson = User.fromJson(json);
print(userFromJson); // User(name: User A, age: 100)
}
Solutions
Now let’s be real. This is a pain to write for every single data class we make, and it only gets worse as we add more fields.
Luckily we have two solutions: Dart Data Class Generator
and Freezed
.
Dart Data Class Generator
Dart Data Class Generator
is a VSCode extension that generates the code for us. We can change the way the class will be generated in our VSCode settings.
All we have to do is define a class with the fields we want. Hover your cursor over a field, tap CMD + .
on Mac or CTRL + .
on Windows, and select Generate Data Class. And now we have our class!
Freezed
The second solution is to use a code generation package like (freezed)[https://pub.dev/packages/freezed]. Just like Dart Data Class Generator, freezed, in combination with some other packages, will handle equality, copyWith
, toJson
, and fromJson
for us. copyWith
will also be able to handle nullable values!
Freezed isn’t limited to generating data classes though. It has another awesome feature called unions that save us a lot of time, which we’ll get into later.
First, let’s add freezed
, freezed_annotation
, and build_runner
to our pubspec
’s dependencies and run flutter pub get
.
# pubspec.yaml
dependencies:
freezed_annotation: ^2.2.0 # or <latest_version>
dev_dependencies:
build_runner: ^2.3.3 # or <latest_version>
freezed: ^2.3.2 # or <latest_version>
Let’s make the same User
class we made at the beginning of the video.
Import freezed_annotation
at the top of the file and then write part user_model.freezed.dart
. It’s very important that the .freezed.dart
file’s name is the same as the file we’re in, or else our code won’t generate properly.
From here, we write class User with _$User {}
and add a freezed
decorator. The freezed
decorator and _$User
are syntax that freezed uses to generate code.
Instead of defining each field as final Type name
, we define a constructor with fields and their types. You can mark fields as required or nullable.
// user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
@freezed
class User with _$User {
const factory User({
required String name,
int? age,
}) = _User;
}
To generate the code, we type into our terminal:
$ flutter pub run build_runner watch --delete-conflicting-outputs
This will run in the background so all codegen related files will regenerate whenever we modify or save a file. We save a lot of time as we don’t have to keep running this command when we make changes to our files.
user_model.freezed.dart
was generated.
To disable any linter warnings and errors in generated files, add these lines into your analysis_options.yaml
:
# analysis_options.yaml
analyzer:
exclude:
- '**/*.g.dart'
- '**/*.freezed.dart'
errors:
invalid_annotation_target: ignore
When we test out our User
class, we have equality and copyWith
, but we’re missing a toJson()
and fromJson()
.
In order to generate these two methods, we have to add the json_annotation
package to dependencies and the json_serializable
package to our devDependencies.
# pubspec.yaml
dependencies:
json_annotation: ^4.8.0 # or <latest_version>
dev_dependencies:
json_serializable: ^6.6.0 # or <latest_version>
In the User
class, add part 'user_model.g.dart'
and add a new factory constructor called fromJson
that returns _$UserFromJson(json)
.
Once we save, the generated file is automatically updated with toJson
and fromJson
we can use!
// user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class User with _$User {
const factory User({
required String name,
int? age,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) =>
_$UserFromJson(json);
}
Custom Methods
Next, let’s look at how to make a custom method in our freezed class. This is useful if we ever need to add custom functionality to our data class such as converting values to Firestore values like Timestamp
or converting Firestore DocumentSnapshot
back into our model.
By adding a private constant User
constructor, we’re able to write custom methods. For fun, let’s return the user’s name multiplied by their age.
// user_model.dart
@freezed
class User with _$User {
const User._();
const factory User({
required String name,
int? age,
}) = _User;
// ...
String forFun() => name * age!;
}
After a few seconds, we can now use our new method!
Data Model Relationships
When you’re building out data classes in your app, it’s common for them to have relationships with each other.
Create a new file called job_model
, import freezed_annotation
, and add parts for the freezed
and g
files.
Each job has a String title
and int level
where the title defaults to 'Software Engineer'
. Add the fromJson
factory constructor for to
and fromJson
.
We want our User
model to also have a List<Job>
field, so we can insert that into our constructor.
// job_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'job_model.freezed.dart';
part 'job_model.g.dart';
@freezed
class Job with _$Job {
const factory Job({
@Default('Software Engineer') String title,
required int level,
}) = _Job;
factory Job.fromJson(Map<String, dynamic> json) =>
_$JobFromJson(json);
}
Let’s assign a Job
to our User
and call toJson()
. When we look at the result, we see Map<String, dynamic>
contains our Job
object still.
// user_model.dart
import 'package:flutter_codegen/job_model.dart';
// ...
@freezed
class User with _$User {
const User._();
const factory User({
required String name,
int? age,
required List<Job> jobs
}) = _User;
// ...
}
void main() {
const userA = User(name: 'User A', age: 20, jobs[Job(level: 3)]);
print(userA.toJson()); // {name: User A, age: 20, jobs: [Job(title: Software Engineer, level: 3)]}
}
To serialize nested lists of freezed objects, we add @JsonSerializable(explicitToJson: true)
above our User
constructor. Run it again, and it works!
// user_model.dart
@freezed
class User with _$User {
const User._();
@JsonSerializable(explicitToJson: true)
const factory User({
// ...
}) = _User;
// ...
}
void main() {
const userA = User(name: 'User A', age: 20, jobs[Job(level: 3)]);
print(userA.toJson()); // {name: User A, age: 20, jobs: [{title: Software Engineer, level: 3}]}
}
Unions/Sealed Classes
We covered creating data classes in dart, so let’s move onto Unions. A Union or Sealed class has several, but fixed types.
I’ll show you a practical example of using Unions with flutter_bloc. If you’re unfamiliar bloc, or business logic component, all you need to know is that when our user interacts with our UI, events are sent to the bloc. The bloc takes these events, performs some business logic, and sends new states back to the UI. The UI renders different widgets based on the state.
I’ve imported flutter_bloc into the project and changed the counter example to use bloc. When the user taps on the increment button, a CounterIncrement
event is sent to the bloc, a loading state is emitted showing a CircularProgressIndicator
, and the counter is incremented. When the user taps the reset button, a CounterReset
event is sent to the bloc, and the bloc resets the counter back to 0. The CounterText
widget watches the state and renders UI accordingly.
To use freezed
with bloc, we’ll edit the event file first. Instead of defining the events and extending the abstract class, we can use freezed to clean this up with a union and return factory constructors.
// counter_event.dart
part of 'counter_bloc.dart';
@freezed
class CounterEvent with _$CounterEvent {
const factory CounterEvent.start() = _CounterStart;
const factory CounterEvent.reset() = _CounterReset;
const factory CounterEvent.increment() = _CounterIncrement;
}
Since counter_event
is part of counter_bloc
, we import freezed_annotation
into the counter_bloc
and add part counter_bloc.freezed.dart
.
// counter_bloc.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'counter_bloc.freezed.dart';
The counter_state
has three states: Initial
, Loading
, and Loaded
. We’ll make three factory constructors. In the Loaded state, we expect to show an integer, so it takes in an int field.
// counter_state.dart
part of 'counter_bloc.dart';
@freezed
class CounterState with _$CounterState {
const factory CounterState.initial() = _CounterInitial;
const factory CounterState.loading() = _CounterLoading;
const factory CounterState.loaded(int count) = _CounterLoaded;
}
Let’s fix up our counter_bloc
with the new states. And change the CounterEvents
to use the factory constructors.
// counter_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'counter_bloc.freezed.dart';
part 'counter_event.dart';
part 'counter_state.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const _CounterInitial()) {
on<CounterStart>((event, emit)) async {
await Future.delayed(const Duration(milliseconds: 2000));
emit(const _CounterLoaded(0));
});
on<CounterReset>((event, emit) async {
emit(const _CounterLoading());
await Future.delayed(const Duration(milliseconds: 1500));
emit(const _CounterLoaded(0));
});
on<CounterIncrement>((event, emit) async {
state.maybeMap(
loaded: (state) {
emit(const _CounterLoading());
await Future.delayed(const Duration(milliseconds: 1200));
emit(_CounterLoaded(state.count + 1)),
}
orElse: () {},
);
});
}
}
// main.dart
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc()..add(const CounterEvent.start()),
child: MaterialApp(
title: 'Flutter Codegen',
theme: ThemeData(primarySwatch: Colors.blue),
home: const CounterScreen(),
),
);
}
}
class CounterScreen extends StatelessWidget {
// ...
FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(const CounterEvent.increment()),
child: const Icon(Icons.add),
)
// ...
FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(const CounterEvent.reset()),
child: const Icon(Icons.refresh),
)
// ...
}
Thanks to pattern matching, we can make the _CounterText
code a lot easier to read.
context.watch
the state
, and then use when
to render different UI based on the current state.
- For
initial
, show a centeredFlutterLogo
. - For
loading
, show aCircularProgressIndicator
. - For
loaded
, show the counter text.
And that’s all we have to do!
// main.dart - _CounterText StatelessWidget
// ❌ Before pattern matching 👇
@override
Widget build(BuildContext context) {
final state = context.watch<CounterBloc>().state;
if (state is CounterInitial) {
return const Center(child: FlutterLogo(size: 120));
} else if (state is CounterLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is CounterLoaded) {
return Center(
child: Text(
'${state.count}',
style: Theme.of(context).textTheme.headline2,
),
);
}
return const SizedBox.shrink();
}
// ✅ After pattern matching 👇
@override
Widget build(BuildContext context) {
final state = context.watch<CounterBloc>().state;
return state.when(
initial: () => const FlutterLogo(size: 120),
loading: () => const CircularProgressIndicator(),
loaded: (count) => Center(
child: Text(
'$count',
style: Theme.of(context).textTheme.headline2,
),
),
);
return const SizedBox.shrink();
}
Wrap Up
Using freezed unions to generate our bloc’s state and event files saved us a lot of time because we didn’t have to write any bloc boilerplate code. In this example, we only used when
for pattern matching, but there’s also maybeWhen
, map
, and maybeMap
you can use depending on the situation.
You learned how to generate data classes, create unions, and utilize unions in your blocs. I hope you incorporate freezed into your own Flutter projects to save yourself a lot of development time.