Freezed and json_serializable in Flutter: solved exercise

Freezed and json_serializable in Flutter: solved exercise

Freezed automatically generates copyWith, ==, hashCode, toString, and fromJson/toJson for your models. It eliminates the boilerplate you would otherwise write by hand — the most common source of bugs when working with immutable data.

Problem statement

Create an immutable Post model with id, title, body, and isFavorite (with a default value). Demonstrate:

  • Instance creation.
  • copyWith to change a single field.
  • Serialization to/from JSON.
  • A union type ApiResult to model success and failure.

Dependencies

1
2
3
4
5
6
7
8
dependencies:
  freezed_annotation: ^2.4.1
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  freezed: ^2.5.7
  json_serializable: ^6.8.0

Running build_runner

1
2
3
4
5
# Generate .freezed.dart and .g.dart for the first time
dart run build_runner build --delete-conflicting-outputs

# Watch mode: regenerates automatically on save
dart run build_runner watch --delete-conflicting-outputs

Solution: Post model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// lib/domain/models/post.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'post.freezed.dart';
part 'post.g.dart';

@freezed
class Post with _$Post {
  const factory Post({
    required int id,
    required String title,
    required String body,
    @Default(false) bool isFavorite,
  }) = _Post;

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}

Solution: ApiResult union type

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// lib/domain/models/api_result.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'api_result.freezed.dart';

@freezed
sealed class ApiResult<T> with _$ApiResult<T> {
  const factory ApiResult.success(T data) = Success<T>;
  const factory ApiResult.failure(String message) = Failure<T>;
}

Full usage in an app

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// lib/main.dart
import 'package:flutter/material.dart';

// Import your generated models
// import 'domain/models/post.dart';
// import 'domain/models/api_result.dart';

void main() {
  // ── Post model ───────────────────────────────────────────────────────────
  const post = Post(id: 1, title: 'Hello Freezed', body: 'Post body');

  // copyWith: creates a copy with isFavorite changed
  final favoritePost = post.copyWith(isFavorite: true);

  // JSON serialization
  final json = post.toJson();
  final fromJson = Post.fromJson({'id': 2, 'title': 'Test', 'body': 'Body'});

  // == works correctly (generated by Freezed)
  assert(post == const Post(id: 1, title: 'Hello Freezed', body: 'Post body'));
  assert(post != favoritePost);

  debugPrint('post: $post');
  // Post(id: 1, title: Hello Freezed, body: Post body, isFavorite: false)

  debugPrint('favoritePost: $favoritePost');
  // Post(id: 1, title: Hello Freezed, body: Post body, isFavorite: true)

  debugPrint('json: $json');
  // {id: 1, title: Hello Freezed, body: Post body, isFavorite: false}

  debugPrint('fromJson: $fromJson');
  // Post(id: 2, title: Test, body: Body, isFavorite: false)

  // ── ApiResult union type ─────────────────────────────────────────────────
  const ApiResult<Post> result = ApiResult.success(post);

  final message = switch (result) {
    Success(:final data) => 'Loaded: ${data.title}',
    Failure(:final message) => 'Error: $message',
  };
  debugPrint(message); // Loaded: Hello Freezed

  runApp(const MaterialApp(home: Scaffold(body: Center(child: Text('Freezed OK')))));
}

Files generated by build_runner

After running build_runner build, two files appear that you must not edit manually:

1
2
3
4
lib/domain/models/
├── post.dart             ← only edit this one
├── post.freezed.dart     ← auto-generated
└── post.g.dart           ← auto-generated (fromJson/toJson)

Add these patterns to .gitignore if you prefer not to version the generated files (requires regeneration in CI):

1
2
3
# .gitignore (optional)
*.freezed.dart
*.g.dart

Or commit them to avoid regenerating on every CI build.

Common mistakes

  • Forgetting part 'post.freezed.dart': the compiler cannot find the generated types and fails with _$Post not found.
  • Not running build_runner after a change: .freezed.dart files become stale and type errors appear.
  • Editing a .freezed.dart file by hand: it gets overwritten on the next build; all changes go in the annotated source file.

When Freezed is worth it

  • Models that travel across multiple layers (API → domain → UI).
  • Models that are frequently copied with small modifications.
  • BLoC/Cubit states with multiple variants (union types).
  • Any project lasting more than a couple of weeks.

Practical use

Freezed is the de facto standard for domain models in Flutter. You will find it in almost every production project with clean architecture, alongside Riverpod or BLoC.

Guided practice and next step

FAQ

Do I need to regenerate on every flutter run?

No. Generated files persist. You only regenerate when you change the annotated class.

Is Freezed compatible with Riverpod?

Yes, and they complement each other very well. StateNotifier or AsyncNotifier states in Riverpod are typically modeled with Freezed.

Can I use Freezed without json_serializable?

Yes. If you do not need fromJson/toJson, omit json_annotation and the part '*.g.dart'. You still get copyWith, ==, and toString.