Networking & REST API Integration

Automated JSON Serialization with json_serializable

15 min Lesson 5 of 13

Automated JSON Serialization with json_serializable

Writing fromJson and toJson methods by hand is tedious and error-prone. The json_serializable package solves this by generating that boilerplate automatically. You annotate your Dart model class, run the build_runner code generator, and the package produces a .g.dart file containing the complete serialization logic. The result is type-safe, zero-maintenance JSON handling.

Note: json_serializable is a dev dependency — it is only needed during development to generate code. The generated .g.dart files are committed to source control and shipped with the app. At runtime, no code-generation tooling is present.

Setting Up the Dependencies

Add the required packages to pubspec.yaml. json_annotation provides the annotations you use in your model. json_serializable and build_runner do the code generation at development time.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  json_annotation: ^4.9.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

Run flutter pub get to install the packages before annotating your models.

Annotating a Model Class

To make a class serializable, you need three things:

  • Import package:json_annotation/json_annotation.dart
  • Add the part directive referencing the generated file
  • Annotate the class with @JsonSerializable()

user.dart — Annotated model

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart'; // generated file will be placed here

@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;

  @JsonKey(name: 'avatar_url') // map snake_case JSON key to camelCase field
  final String avatarUrl;

  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  const User({
    required this.id,
    required this.name,
    required this.email,
    required this.avatarUrl,
    required this.createdAt,
  });

  // Factory and method delegate to the generated code:
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

Running the Code Generator

Once your model is annotated, run one of the following commands from the project root:

  • One-shot build: dart run build_runner build --delete-conflicting-outputs
  • Watch mode: dart run build_runner watch --delete-conflicting-outputs

The generator creates user.g.dart alongside user.dart. You should never edit this file manually — it will be overwritten on the next build.

Tip: Use watch mode during active development. It monitors your source files and regenerates the .g.dart files instantly whenever you save a change to an annotated class, so you never need to remember to re-run the build manually.

What the Generated File Contains

After running the generator, user.g.dart will contain two top-level functions that the model delegates to:

user.g.dart — Generated output (do not edit)

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

User _$UserFromJson(Map<String, dynamic> json) => User(
      id: (json['id'] as num).toInt(),
      name: json['name'] as String,
      email: json['email'] as String,
      avatarUrl: json['avatar_url'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
      'id': instance.id,
      'name': instance.name,
      'email': instance.email,
      'avatar_url': instance.avatarUrl,
      'created_at': instance.createdAt.toIso8601String(),
    };

Useful @JsonSerializable Options

The @JsonSerializable annotation accepts optional parameters to customize behaviour:

  • fieldRename: FieldRename.snake — automatically maps all camelCase fields to snake_case JSON keys without needing a @JsonKey on every field.
  • explicitToJson: true — required when a field is itself a serializable object; otherwise nested objects are not converted to maps.
  • includeIfNull: false — omits fields with null values from the serialized output.
  • createToJson: false — skip generating toJson when you only need deserialization (e.g., read-only API responses).

Nested objects with explicitToJson

@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
class Post {
  final int id;
  final String title;
  final User author; // nested serializable object

  const Post({required this.id, required this.title, required this.author});

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}
Warning: If you forget explicitToJson: true when a model contains nested serializable fields, toJson() will include the nested object as a Dart instance rather than a Map. This typically causes a runtime error or incorrect JSON output when encoding.

Handling Lists and Nullable Fields

The generator handles List<T>, Map<K, V>, and nullable types (T?) without extra configuration. Declare the types accurately and the generated code will cast and null-check correctly.

Model with list and nullable fields

@JsonSerializable(explicitToJson: true)
class ApiResponse {
  final bool success;
  final String? message;       // nullable — may be absent in JSON
  final List<User> data;       // list of nested objects

  const ApiResponse({
    required this.success,
    this.message,
    required this.data,
  });

  factory ApiResponse.fromJson(Map<String, dynamic> json) =>
      _$ApiResponseFromJson(json);
  Map<String, dynamic> toJson() => _$ApiResponseToJson(this);
}

Summary

The json_serializable workflow keeps your model classes clean and declarative. You describe the shape of your data once using annotations, run the generator, and get battle-tested serialization code for free. Key points to remember:

  • Add json_annotation as a runtime dependency and build_runner + json_serializable as dev dependencies.
  • Annotate the class with @JsonSerializable() and add the part directive.
  • Run dart run build_runner build --delete-conflicting-outputs to generate the .g.dart file.
  • Use @JsonKey(name: ...) for custom key mappings and explicitToJson: true for nested objects.
  • Commit generated .g.dart files — they are part of the compiled app.