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 aUser
instance from a map structure.A
toJson
method for converting aUser
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 .
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.