Launch Club

Flutter Freezed Code Generation

Flutter Freezed Bloc

Published Apr 25, 2023

Marcus Ng

Marcus Ng

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 Extension

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.

Dart Data Class Generator 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!

Dart Data Class Generation

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 centered FlutterLogo.
  • For loading, show a CircularProgressIndicator.
  • 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.

Flutter and Dart

made simple.

Everything you need to build production ready apps.

No spam. Just updates. Unsubscribe whenever.