json_serializable with build_runner in Flutter: solved exercise

json_serializable with build_runner in Flutter: solved exercise

Writing fromJson and toJson by hand is repetitive and error-prone. json_serializable generates that code automatically from annotations, and is lighter than Freezed when you only need serialization without immutability or union types.

Problem statement

Create a User model with required and optional fields, serializable to/from JSON. Demonstrate:

  • Fields with a different JSON name (@JsonKey).
  • Optional fields with default values.
  • Serialization of nested lists.
  • Parsing a list of users from a JSON array.

Dependencies

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

dev_dependencies:
  build_runner: ^2.4.13
  json_serializable: ^6.8.0

Running build_runner

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

# Watch mode while developing
dart run build_runner watch --delete-conflicting-outputs

Solution: User model

 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;

  /// The API returns "user_name" but Dart uses camelCase.
  @JsonKey(name: 'user_name')
  final String userName;

  final String email;

  /// Optional field: the API may omit it.
  @JsonKey(defaultValue: 'en')
  final String locale;

  /// User roles list.
  @JsonKey(defaultValue: [])
  final List<String> roles;

  const UserDto({
    required this.id,
    required this.userName,
    required this.email,
    this.locale = 'en',
    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)';
}

Solution: list parsing helper

1
2
3
4
5
// lib/data/models/user_dto.dart (add at the end)

/// Parses a JSON array of users directly.
List<UserDto> userListFromJson(List<dynamic> json) =>
    json.map((e) => UserDto.fromJson(e as Map<String, dynamic>)).toList();

Full usage

 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 from the API (snake_case and a missing optional field)
  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: en, roles: [admin, editor])
  // UserDto(id: 2, userName: bob, email: bob@example.com, locale: en, roles: [])

  // Serialize back to JSON
  final backToJson = users.first.toJson();
  debugPrint('JSON: $backToJson');
  // {id: 1, user_name: alice, email: alice@example.com, locale: en, 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,
        ),
      ),
    ),
  ));
}

Generated file: user_dto.g.dart

After the build you get something like this (do not edit it):

 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? ?? 'en',
      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,
    };

Common mistakes

  • Forgetting part 'user_dto.g.dart': the compiler cannot find _$UserDtoFromJson and fails.
  • Renaming a field without updating @JsonKey: the field is not deserialized and silently gets its default value.
  • Not adding @JsonKey(defaultValue: ...) for optional fields: if the API omits the field, fromJson throws Null check operator used on a null value.

json_serializable vs Freezed

Needjson_serializableFreezed
fromJson / toJson
Generated copyWith
Generated == and hashCode
Union types
Fewer dependencies

Use json_serializable alone if you do not need deep immutability. For medium and large projects, Freezed is worth the extra setup.

Practical use

Any app consuming a JSON API benefits from this workflow: define the DTO, annotate, run build_runner, and get robust typed serialization with no manual code.

Guided practice and next step

FAQ

Does build_runner generate code for all annotated classes at once?

Yes. A single build_runner build processes all annotated files in the project. There is no native way to process a single file selectively.

Can I use explicitToJson: true for nested classes?

Yes. Add @JsonSerializable(explicitToJson: true) if you have nested objects that also have toJson; otherwise they serialize as raw Map<String, dynamic> without calling their own toJson.

Does it work with enums?

Yes. Add @JsonValue('json_value') to each enum entry and @JsonKey(unknownEnumValue: MyEnum.unknown) on the field to handle unexpected API values gracefully.