Freezed y json_serializable en Flutter: ejercicio resuelto

Freezed y json_serializable en Flutter: ejercicio resuelto

Freezed genera automáticamente copyWith, ==, hashCode, toString y fromJson/toJson para tus modelos. Elimina el boilerplate que de otro modo escribirías a mano y que es la fuente de errores más común al trabajar con datos inmutables.

Enunciado

Crea un modelo Post inmutable con id, title, body y isFavorite (con valor por defecto). Demuestra:

  • Creación de instancias.
  • copyWith para modificar un campo.
  • Serialización a/desde JSON.
  • Un tipo union ApiResult para modelar éxito y error.

Dependencias

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

Cómo ejecutar build_runner

1
2
3
4
5
# Genera los archivos .freezed.dart y .g.dart por primera vez
dart run build_runner build --delete-conflicting-outputs

# Modo watch: regenera automáticamente al guardar
dart run build_runner watch --delete-conflicting-outputs

Solución: modelo Post

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

Solución: tipo union ApiResult

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

Uso completo en una 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';

// Importa tus modelos generados
// import 'domain/models/post.dart';
// import 'domain/models/api_result.dart';

void main() {
  // ── Modelo Post ──────────────────────────────────────────────────────────
  const post = Post(id: 1, title: 'Hola Freezed', body: 'Cuerpo del post');

  // copyWith: crea una copia con isFavorite cambiado
  final favoritePost = post.copyWith(isFavorite: true);

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

  // == funciona correctamente (generado por Freezed)
  assert(post == const Post(id: 1, title: 'Hola Freezed', body: 'Cuerpo del post'));
  assert(post != favoritePost);

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

  debugPrint('favoritePost: $favoritePost');
  // Post(id: 1, title: Hola Freezed, body: Cuerpo del post, isFavorite: true)

  debugPrint('json: $json');
  // {id: 1, title: Hola Freezed, body: Cuerpo del post, isFavorite: false}

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

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

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

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

Archivos generados por build_runner

Después de ejecutar build_runner build aparecen dos archivos que no debes editar a mano:

1
2
3
4
lib/domain/models/
├── post.dart             ← solo editas este
├── post.freezed.dart     ← generado automáticamente
└── post.g.dart           ← generado automáticamente (fromJson/toJson)

Añade estos patrones a .gitignore si prefieres no versionar los archivos generados (requiere regenerar en CI):

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

O bien versiónalos para evitar regenerar en cada build de CI.

Errores frecuentes

  • Olvidar part 'post.freezed.dart': el compilador no encuentra los tipos generados y falla con _$Post not found.
  • No ejecutar build_runner después de un cambio: los archivos .freezed.dart quedan desactualizados y aparecen errores de tipos.
  • Modificar un archivo .freezed.dart a mano: se sobreescribe en el siguiente build; cualquier cambio va en el archivo fuente anotado.

Cuándo merece la pena Freezed

  • Modelos que viajen por varias capas (API → dominio → UI).
  • Modelos que se copian frecuentemente con pequeñas modificaciones.
  • Estados de BLoC/Cubit con múltiples variantes (union types).
  • Cualquier proyecto de más de un par de semanas.

Aplicación práctica

Freezed es el estándar de facto para modelos de dominio en Flutter. Lo encontrarás en casi todos los proyectos de producción con arquitectura limpia, junto a Riverpod o BLoC.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Tengo que regenerar en cada flutter run?

No. Los archivos generados persisten. Solo regeneras cuando cambias la clase anotada.

¿Freezed es compatible con Riverpod?

Sí, y se complementan muy bien. Los estados de StateNotifier o AsyncNotifier de Riverpod suelen modelarse con Freezed.

¿Puedo usar Freezed sin json_serializable?

Sí. Si no necesitas fromJson/toJson, omite json_annotation y el part '*.g.dart'. Obtendrás copyWith, == y toString igual.