Flutter (83): Convert JSON to Dart Model class

Time: Column:Mobile & Frontend views:227

83.1 JSON to Dart Classes

1. Introduction

In practical applications, backend APIs often return structured data such as JSON or XML. For example, the data returned from requesting the GitHub API is a JSON-formatted string. To facilitate working with JSON in our code, we first convert the JSON-formatted string into Dart objects. This can be accomplished using the built-in JSON decoder json.decode() from the dart:convert library. This method can convert a JSON string into a List or Map, allowing us to retrieve the desired values. For example:

// A JSON-formatted user list string
String jsonStr = '[{"name":"Jack"},{"name":"Rose"}]';
// Convert the JSON string to a Dart object (in this case, a List)
List items = json.decode(jsonStr);
// Output the name of the first user
print(items[0]["name"]);

The method of converting a JSON string to a List/Map using json.decode() is straightforward, with no external dependencies or additional setup, making it convenient for small projects. However, as projects grow larger, manually writing serialization logic can become difficult to manage and prone to errors. For example, given the following JSON:

{
  "name": "John Smith",
  "email": "john@example.com"}

We can decode the JSON using the json.decode method, using the JSON string as a parameter:

Map<String, dynamic> user = json.decode(json);
print('Howdy, ${user['name']}!');
print('We sent the verification link to ${user['email']}.');

Since json.decode() only returns a Map<String, dynamic>, we only know the types of the values at runtime. This approach leads to a loss of many features of statically typed languages: type safety, autocompletion, and, most importantly, compile-time errors. Consequently, our code can become very error-prone. For example, if we mistype a field name when accessing the name or email fields, the compiler won’t catch the error, as the JSON is in a map structure.

This issue is common across many platforms, and a well-established solution is "JSON modeling." The approach involves defining model classes that correspond to the JSON structure, and then dynamically creating instances of these model classes based on the data received. This way, during development, we work with instances of model classes instead of Map/List, thereby reducing the risk of typos when accessing internal properties. For instance, we can introduce a simple model class called User to resolve the previously mentioned issues. In the User class, we have:

  • A User.fromJson constructor for creating a User instance from a map structure.

  • A toJson method for converting a User instance back into a map.

This allows our calling code to maintain type safety, field autocompletion (for name and email), and compile-time errors. If we mistype a field as an int instead of a String, our code will fail to compile rather than crashing at runtime.

// user.dart
class User {
  final String name;
  final String email;

  User(this.name, this.email);

  User.fromJson(Map<String, dynamic> json)
      : name = json['name'],
        email = json['email'];

  Map<String, dynamic> toJson() => 
    <String, dynamic>{
      'name': name,
      'email': email,
    };
}

Now, the serialization logic is encapsulated within the model itself. Using this new approach, we can easily deserialize a user:

Map userMap = json.decode(json);
var user = User.fromJson(userMap);
print('Howdy, ${user.name}!');
print('We sent the verification link to ${user.email}.');

To serialize a user, we simply pass the User object to the json.encode method. We don’t need to manually call the toJson method, as json.encode will automatically call it.

String json = json.encode(user);

This way, the calling code doesn't need to worry about JSON serialization, but the model class is still essential. In practice, both the User.fromJson and User.toJson methods should be unit-tested to ensure correct behavior.

Additionally, in real scenarios, JSON objects are rarely this simple; nested JSON objects are quite common. It would be great to have something that automatically handles JSON serialization for us. Fortunately, there is!

2. Automatically Generating Models

Although there are other libraries available, in this book, we will introduce the officially recommended json_serializable package. It is an automated source code generator that generates JSON serialization templates for us during the development phase, minimizing the risk of runtime JSON serialization exceptions by eliminating the need for us to manually write and maintain serialization code.

1) Setting Up json_serializable in the Project

To include json_serializable in our project, we need one regular dependency and two development dependencies. In brief, development dependencies are not included in our application source code; they serve as auxiliary tools or scripts during the development process, similar to development dependencies in Node.js.

pubspec.yaml

dependencies:
  json_annotation: <latest_version>dev_dependencies:
  build_runner: <latest_version>
  json_serializable: <latest_version>

Run flutter packages get in your project’s root folder (or click “Packages Get” in your editor) to use these new dependencies.

2) Creating Model Classes with json_serializable

Let’s see how to convert our User class into one that uses json_serializable. For simplicity, we will use the simplified JSON model from the previous example.

user.dart

import 'package:json_annotation/json_annotation.dart';

// user.g.dart will be automatically generated after we run the generation command
part 'user.g.dart';

/// This annotation tells the generator that this class needs a generated Model class
@JsonSerializable()
class User {
  User(this.name, this.email);

  String name;
  String email;

  // Different classes use different mixins
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);  
}

With the above setup, the source code generator will create JSON code for serializing the name and email fields.

If needed, custom naming strategies can be easily implemented. For example, if the API we are using returns objects with snake_case, but we want to use _lowerCamelCase_ in our model, we can use the @JsonKey annotation:

// Explicitly associate JSON field names with Model properties
@JsonKey(name: 'registration_date_millis')
final int registrationDateMillis;
3) Running the Code Generator

When json_serializable first creates classes, you may see errors similar to those in Figure .

Flutter (83): Convert JSON to Dart Model class

These errors are completely normal, as the generated code for the model class does not yet exist. To resolve this issue, we must run the code generator to create serialization templates for us. There are two ways to run the code generator:

One-Time Generation

You can trigger a one-time build by running the following command in your project’s root directory:

flutter packages pub run build_runner build

This triggers a one-time build, allowing us to generate JSON serialization code for our models as needed. It scans our source files to find those that need model classes (those annotated with @JsonSerializable) and generates the corresponding .g.dart files. A good practice is to place all model classes in a separate directory and execute the command in that directory.

While this is convenient, it would be better if we didn’t have to manually run the build command every time we change the model classes.

Continuous Generation

Using a watcher makes the source code generation process more convenient. It monitors file changes in our project and automatically builds necessary files when needed. We can start the watcher by running:

flutter packages pub run build_runner watch

This command will run in the background and automatically handle changes, making development smoother.


83.2 One Command to Convert JSON to Dart Classes

1. Implementation

The major issue with the previous method is that a template must be written for every JSON structure, which can be tedious. If there were a tool to automatically generate templates based on JSON text, we could completely free our hands. I have created a Dart script that can automatically generate templates and convert JSON directly into Model classes. Let's see how it's done:

Define a "template of templates" named template.dart:

import 'package:json_annotation/json_annotation.dart';
%t
part '%s.g.dart';

@JsonSerializable()
class %s {
  %s();

  %s
  factory %s.fromJson(Map<String, dynamic> json) => _$%sFromJson(json);
  Map<String, dynamic> toJson() => _$%sToJson(this);
}

In this template, %t and %s are placeholders that will be dynamically replaced with appropriate import statements and class names during script execution.

Next, write an automatic template generation script (mo.dart). It will traverse a specified JSON directory and generate templates based on certain rules:

  • If the JSON filename starts with an underscore _, ignore that JSON file.

  • Complex JSON objects often have nested structures; we can manually specify nested objects with a special flag (examples later).

Here is the Dart script:

import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;

const TAG = "\$";
const SRC = "./json"; // JSON directory
const DIST = "lib/models/"; // Output model directory

void walk() {
  // Traverse JSON directory to generate templates
  var src = Directory(SRC);
  var list = src.listSync();
  var template = File("./template.dart").readAsStringSync();
  File file;
  list.forEach((f) {
    if (FileSystemEntity.isFileSync(f.path)) {
      file = File(f.path);
      var paths = path.basename(f.path).split(".");
      String name = paths.first;
      if (paths.last.toLowerCase() != "json" || name.startsWith("_")) return;
      // Generate template
      var map = json.decode(file.readAsStringSync());
      var set = Set<String>();
      StringBuffer attrs = StringBuffer();
      (map as Map<String, dynamic>).forEach((key, v) {
        if (key.startsWith("_")) return;
        attrs.write(getType(v, set, name) + "?");
        attrs.write(" ");
        attrs.write(key);
        attrs.writeln(";");
        attrs.write("    ");
      });
      String className = name[0].toUpperCase() + name.substring(1);
      var dist = format(template, [
        name,
        className,
        className,
        attrs.toString(),
        className,
        className,
        className
      ]);
      var _import = set.join(";\r\n");
      _import += _import.isEmpty ? "" : ";";
      dist = dist.replaceFirst("%t", _import);
      // Output the generated template
      File("$DIST$name.dart").writeAsStringSync(dist);
    }
  });
}

// Other functions omitted for brevity...

void main() {
  walk();
}

Next, create a shell script (mo.sh) to tie together template generation and model generation:

dart mo.dart
flutter packages pub run build_runner build --delete-conflicting-outputs

Now, our script is complete! We can create a json directory in the root and move user.json into it. Then, create a models directory under lib to save the generated Model classes. With just one command, we can generate the Model classes:

./mo.sh

After running, everything will be executed automatically, which is much better! However, this script only handles simple JSON cases and may not effectively deal with nested JSON and arrays.

2. Handling Nested JSON

Let's create a person.json file with the following content:

{
  "name": "John Smith",
  "email": "john@example.com",
  "mother": {
    "name": "Alice",
    "email": "alice@example.com"
  },
  "friends": [
    {
      "name": "Jack",
      "email": "Jack@example.com"
    },
    {
      "name": "Nancy",
      "email": "Nancy@example.com"
    }
  ]}

Each Person has four fields: name, email, mother, and friends. Since mother is also a Person and friends is an array of Person objects, we expect the generated Model to look like this:

import 'package:json_annotation/json_annotation.dart';
part 'person.g.dart';

@JsonSerializable()
class Person {
    Person();
    
    String? name;
    String? email;
    Person? mother;
    List<Person>? friends;

    factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
    Map<String, dynamic> toJson() => _$PersonToJson(this);
}

Now, we just need to modify the JSON slightly by adding some special flags and rerun mo.sh:

{
  "name": "John Smith",
  "email": "john@example.com",
  "mother": "$person",
  "friends": "$[]person"}

We use the dollar sign $ as a special flag (you can customize this in mo.dart if there's a conflict). The script will convert the respective fields into corresponding objects or object arrays when it encounters the special flags. Object arrays need to add an array symbol [] after the flag, followed by the specific type name. For example, if we add a Person type boss field to User:

{
  "name": "John Smith",
  "email": "john@example.com",
  "boss": "$person"}

Rerunning mo.sh generates user.dart like this:

import 'package:json_annotation/json_annotation.dart';
import "person.dart";
part 'user.g.dart';

@JsonSerializable()
class User {
    User();

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

As you can see, the boss field is automatically added, and "person.dart" is imported.

3. Json_model Package

The script we implemented above is quite basic and lacks many features, such as defaulting generated variables to nullable types, supporting imports from other Dart files, or generating comments. To address this, I have released a fully functional Json_model package, which includes flexible configuration and customization. Once developers add this package as a development dependency, they can generate Dart classes from JSON files with a single command. Here’s a simple demo:

The JSON file is as follows:

{
  "@meta": { 
    "import": [
      "test_dir/profile.dart" 
    ],
    "comments": {
      "name": "名字" 
    },
    "nullable": false, 
    "ignore": false 
  },
  "@JsonKey(ignore: true) Profile?": "profile",
  "@JsonKey(name: '+1') int?": "loved",
  "name": "wendux",
  "father": "$user",
  "friends": "$[]user",
  "keywords": "$[]String",
  "age?": 20 }

The generated Model class will look like this:

import 'package:json_annotation/json_annotation.dart';
import 'test_dir/profile.dart';
part 'user.g.dart';

@JsonSerializable()
class User {
  User();

  @JsonKey(ignore: true) Profile? profile;
  @JsonKey(name: '+1') int? loved;
  late String name;
  late User father;
  late List<User> friends;
  late List<String> keywords;
  num? age;
  
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

4. FAQ

Many may wonder if there is a JSON serialization library in Flutter similar to Gson/Jackson in Java development. The answer is no! Such libraries would require runtime reflection, which is disabled in Flutter. Runtime reflection interferes with Dart's tree shaking, which can significantly optimize application size by removing unused code in release builds. Since reflection applies to all code by default, tree shaking struggles to identify unused code, making it hard to strip redundant code. Therefore, Dart's reflection capabilities are disabled in Flutter, preventing the implementation of dynamic Model conversion.