json_serializable con build_runner en Flutter: ejercicio resuelto

json_serializable con build_runner en Flutter: ejercicio resuelto

Escribir fromJson y toJson a mano es repetitivo y propenso a errores. json_serializable genera ese código automáticamente a partir de anotaciones, y es más ligero que Freezed cuando solo necesitas serialización sin inmutabilidad ni union types.

Enunciado

Crea un modelo User con campos obligatorios y opcionales, serializable desde/hacia JSON. Demuestra:

  • Campos con nombre diferente en JSON (@JsonKey).
  • Campos opcionales con valor por defecto.
  • Serialización de listas anidadas.
  • Lista de usuarios desde un JSON array.

Dependencias

1
2
3
4
5
6
dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  json_serializable: ^6.8.0

Cómo ejecutar build_runner

1
2
3
4
5
# Genera el archivo user.g.dart
dart run build_runner build --delete-conflicting-outputs

# Modo watch mientras desarrollas
dart run build_runner watch --delete-conflicting-outputs

Solución: modelo User

 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
// lib/data/models/user_dto.dart
import 'package:json_annotation/json_annotation.dart';

part 'user_dto.g.dart';

@JsonSerializable()
class UserDto {
  final int id;

  /// La API devuelve "user_name" pero en Dart usamos camelCase.
  @JsonKey(name: 'user_name')
  final String userName;

  final String email;

  /// Campo opcional: la API puede omitirlo.
  @JsonKey(defaultValue: 'es')
  final String locale;

  /// Lista de roles del usuario.
  @JsonKey(defaultValue: [])
  final List<String> roles;

  const UserDto({
    required this.id,
    required this.userName,
    required this.email,
    this.locale = 'es',
    this.roles = const [],
  });

  factory UserDto.fromJson(Map<String, dynamic> json) =>
      _$UserDtoFromJson(json);

  Map<String, dynamic> toJson() => _$UserDtoToJson(this);

  @override
  String toString() =>
      'UserDto(id: $id, userName: $userName, email: $email, locale: $locale, roles: $roles)';
}

Solución: función de parseo de lista

1
2
3
4
5
// lib/data/models/user_dto.dart (añadir al final)

/// Parsea un array JSON de usuarios directamente.
List<UserDto> userListFromJson(List<dynamic> json) =>
    json.map((e) => UserDto.fromJson(e as Map<String, dynamic>)).toList();

Uso completo

 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
46
47
48
49
50
51
52
53
// lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
// import 'data/models/user_dto.dart';

void main() {
  // JSON que viene de la API (con snake_case y campo faltante)
  const jsonString = '''
  [
    {
      "id": 1,
      "user_name": "alice",
      "email": "alice@example.com",
      "roles": ["admin", "editor"]
    },
    {
      "id": 2,
      "user_name": "bob",
      "email": "bob@example.com"
    }
  ]
  ''';

  final decoded = jsonDecode(jsonString) as List<dynamic>;
  final users = userListFromJson(decoded);

  for (final user in users) {
    debugPrint(user.toString());
  }
  // UserDto(id: 1, userName: alice, email: alice@example.com, locale: es, roles: [admin, editor])
  // UserDto(id: 2, userName: bob, email: bob@example.com, locale: es, roles: [])

  // Serializar de vuelta a JSON
  final backToJson = users.first.toJson();
  debugPrint('JSON: $backToJson');
  // {id: 1, user_name: alice, email: alice@example.com, locale: es, roles: [admin, editor]}

  runApp(MaterialApp(
    home: Scaffold(
      body: ListView.builder(
        itemCount: users.length,
        itemBuilder: (_, i) => ListTile(
          leading: CircleAvatar(child: Text('${users[i].id}')),
          title: Text(users[i].userName),
          subtitle: Text(users[i].email),
          trailing: users[i].roles.isNotEmpty
              ? Chip(label: Text(users[i].roles.first))
              : null,
        ),
      ),
    ),
  ));
}

Archivo generado: user_dto.g.dart

Después del build obtienes algo así (no lo edites):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// GENERATED CODE - DO NOT MODIFY BY HAND
UserDto _$UserDtoFromJson(Map<String, dynamic> json) => UserDto(
      id: (json['id'] as num).toInt(),
      userName: json['user_name'] as String,
      email: json['email'] as String,
      locale: json['locale'] as String? ?? 'es',
      roles: (json['roles'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
    );

Map<String, dynamic> _$UserDtoToJson(UserDto instance) => <String, dynamic>{
      'id': instance.id,
      'user_name': instance.userName,
      'email': instance.email,
      'locale': instance.locale,
      'roles': instance.roles,
    };

Errores frecuentes

  • Olvidar part 'user_dto.g.dart': el compilador no encuentra _$UserDtoFromJson y falla.
  • Cambiar el nombre del campo sin actualizar @JsonKey: el campo no se deserializa y queda con el valor por defecto silenciosamente.
  • No añadir @JsonKey(defaultValue: ...) en campos opcionales: si la API omite el campo, fromJson lanza Null check operator used on a null value.

json_serializable vs Freezed

Necesidadjson_serializableFreezed
fromJson / toJson
copyWith generado
== y hashCode generados
Union types
Menos dependencias

Usa json_serializable solo si no necesitas inmutabilidad profunda. En proyectos medianos y grandes, Freezed merece la pena.

Aplicación práctica

Cualquier app que consuma una API JSON se beneficia de este flujo: defines el DTO, anotas, ejecutas build_runner y ya tienes serialización robusta y tipada sin código manual.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Genera build_runner código para todas las clases del proyecto a la vez?

Sí. Un solo build_runner build procesa todos los archivos anotados del proyecto. Si solo quieres procesar un archivo, no hay forma nativa de hacerlo selectivamente.

¿Puedo usar explicitToJson: true para clases anidadas?

Sí. Añade @JsonSerializable(explicitToJson: true) si tienes objetos anidados que también tienen toJson; de lo contrario se serializan como Map<String, dynamic> sin llamar a su toJson.

¿Funciona con enums?

Sí. Añade @JsonValue('valor_en_json') a cada valor del enum y @JsonKey(unknownEnumValue: MiEnum.unknown) en el campo para manejar valores inesperados de la API.