17  JSON serializable

Let’s explore json_serializable, which is one of Dart’s most powerful tools for handling JSON data. Think of it as moving from writing JSON parsing code by hand to having a smart assistant that writes all that boilerplate code for you automatically.

17.1 What is json_serializable?

The json_serializable package is a code generation tool that automatically creates the fromJson and toJson methods we wrote manually earlier. It’s similar to how Python’s dataclasses or JavaScript’s decorators can automatically generate repetitive code, but json_serializable is specifically designed for JSON serialization.

When you worked with our manual approach earlier, you had to write the parsing logic yourself. With json_serializable, you simply annotate your class with @JsonSerializable(), and the tool generates all the parsing and serialization code for you at build time.

17.2 The Purpose and Benefits

The primary purpose is to eliminate the tedious and error-prone task of writing JSON serialization code by hand. Consider what happens as your data models grow more complex. A simple User class might have just two fields, but what about a medical record with dozens of fields, nested objects, lists, and optional parameters? Writing all that parsing logic manually becomes a nightmare.

Here’s what json_serializable provides:

Automatic Code Generation: It writes the fromJson and toJson logic for you, handling type conversions, null safety, and edge cases automatically.

Type Safety: The generated code respects Dart’s type system, ensuring that your JSON parsing is compile-time safe rather than relying on runtime type checks.

Maintainability: When you add or modify fields in your class, you simply regenerate the code rather than manually updating parsing logic in multiple places.

Performance: The generated code is optimized and often faster than hand-written parsing code because it’s generated specifically for your data structure.

17.3 Understanding Your Example

Let’s break down your code snippet piece by piece:

/// An annotation for the code generator to know that this class needs the
/// JSON serialization logic to be generated.
@JsonSerializable()
class User {
  User(this.name, this.email);

  String name;
  String email;

  /// A necessary factory constructor for creating a new User instance
  /// from a map. Pass the map to the generated `_$UserFromJson()` constructor.
  /// The constructor is named after the source class, in this case, User.
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  /// `toJson` is the convention for a class to declare support for serialization
  /// to JSON. The implementation simply calls the private, generated
  /// helper method `_$UserToJson`.
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

The Annotation: The @JsonSerializable() annotation is a marker that tells the code generator “please create JSON serialization code for this class.” It’s similar to Python decorators or Java annotations, but it works at compile time rather than runtime.

The Factory Constructor: The factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); line is particularly interesting. It’s a factory constructor that delegates to a generated function. Notice the naming convention: _$UserFromJson follows the pattern _$ClassNameFromJson. The underscore prefix indicates it’s a private, generated function.

The toJson Method: Similarly, toJson() delegates to the generated _$UserToJson(this) function. This maintains the same interface as our manual approach, but the implementation is automatically generated.

17.4 The Complete Setup Process

To use json_serializable, you need to set up several moving parts. First, you’ll need to add dependencies to your pubspec.yaml:

dependencies:
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.7
  json_serializable: ^6.7.1

Notice that json_annotation is a regular dependency (needed at runtime), while json_serializable and build_runner are dev dependencies (only needed during development for code generation).

After adding these dependencies, your complete User class would look like this:

import 'package:json_annotation/json_annotation.dart';

// This tells the generator where to find the generated code
part 'user.g.dart';

@JsonSerializable()
class User {
  User(this.name, this.email);

  String name;
  String email;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

The part 'user.g.dart'; directive tells Dart that this file has a companion file containing generated code. The .g.dart suffix is a convention indicating generated code.

17.5 The Code Generation Process

To actually generate the code, you run this command in your terminal:

dart run build_runner build

This command analyzes your code, finds all the @JsonSerializable() annotations, and generates the corresponding .g.dart files. After running this, you’ll have a user.g.dart file that contains something like:

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user.dart';

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

User _$UserFromJson(Map<String, dynamic> json) => User(
      json['name'] as String,
      json['email'] as String,
    );

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
      'name': instance.name,
      'email': instance.email,
    };

Notice how this generated code looks remarkably similar to what we wrote manually earlier, but it’s created automatically and follows best practices for error handling and type safety.

17.6 Advanced Configuration

The @JsonSerializable() annotation accepts various parameters to customize the generated code. For example, you might want to handle JSON keys that don’t match your Dart field names:

@JsonSerializable()
class User {
  User(this.name, this.email);

  @JsonKey(name: 'full_name')
  String name;
  
  @JsonKey(name: 'email_address')
  String email;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

This would handle JSON that looks like {"full_name": "John Smith", "email_address": "john@example.com"} while keeping your Dart field names clean and conventional.

17.7 Comparing the Approaches

Think of the evolution like this: manual JSON parsing is like writing assembly code, while json_serializable is like using a high-level programming language. You get the same result, but with much less effort and fewer opportunities for bugs.

The manual approach gives you complete control but requires you to write and maintain all the parsing logic. The json_serializable approach trades some control for convenience and safety, but covers the vast majority of use cases with much less code.

17.8 When to Use Each Approach

For simple, one-off JSON parsing where you need maximum control or have very specific requirements, the manual approach might be appropriate. However, for most production Flutter applications, especially those dealing with APIs and complex data structures, json_serializable is the recommended approach.

As you build more complex Flutter applications, you’ll likely encounter scenarios with nested objects, lists of objects, and optional fields. The json_serializable approach scales much better to these complex scenarios than manual parsing.

Would you like to try extending this example with more complex data structures, such as nested objects or lists? That would give you a good sense of where json_serializable really shines compared to manual parsing.