Flutter tutorials and courses by Andrea Bizzotto

How to Parse JSON in Dart/Flutter with Code Generation using Freezed

In the previous article, we have learned how to parse JSON into type-safe model classes in Dart.

But writing all the JSON parsing code by hand becomes time-consuming and error-prone if we have a lot of model classes. Luckily, we can use code generation tools such as json_serializable and Freezed to automate the process.

In this article, we'll learn how to parse JSON data with code generation using the Freezed package. And I'll also share a VS Code extension that makes the process even easier. 🙂

Ready? Let's go!

Installing the codegen dependencies

To get things working we're going to need a few dependencies in our pubspec.yaml file:

dependencies: flutter: sdk: flutter freezed_annotation: ^0.14.2 json_annotation: ^4.0.1 dev_dependencies: build_runner: ^2.0.6 freezed: ^0.14.2 json_serializable: ^4.1.4

5 new dependencies? Really?

Yes. Here's what they do:

  • json_serializable: provides the Dart Build System with some builders for handling JSON
  • json_annotation: defines the annotations used by json_serializable
  • freezed: a powerful code generator that can handle complex use-cases with a simple API
  • freezed_annotation: defines the annotations used by freezed
  • build_runner: this is a standalone build package that can generate Dart files for us

Sounds complicated? Don't worry: As long as you import all the required packages, it will be fine. 👍

You can generate the JSON parsing code with json_serializable alone (without freezed). However, freezed is more powerful and can handle complex use-cases with a simple API.

A sample JSON document

To keep things in line with the previous article, we'll reuse the same sample JSON:

{ "name": "Pizza da Mario", "cuisine": "Italian", "year_opened": 1990, "reviews": [ { "score": 4.5, "review": "The pizza was amazing!" }, { "score": 5.0, "review": "Very friendly staff, excellent service!" } ] }

For reference, here are the Restaurant and Review model classes we had written before:

class Restaurant { Restaurant({ required this.name, required this.cuisine, this.yearOpened, required this.reviews, }); final String name; final String cuisine; final int? yearOpened; final List<Review> reviews; factory Restaurant.fromMap(Map<String, dynamic> data) { final name = data['name'] as String; final cuisine = data['cuisine'] as String; final yearOpened = data['year_opened'] as int?; final reviewsData = data['reviews'] as List<dynamic>?; final reviews = reviewsData != null ? reviewsData.map((reviewData) => Review.fromMap(reviewData)).toList() : <Review>[]; return Restaurant( name: name, cuisine: cuisine, yearOpened: yearOpened, reviews: reviews, ); } Map<String, dynamic> toMap() { return { 'name': name, 'cuisine': cuisine, if (yearOpened != null) 'year_opened': yearOpened, 'reviews': reviews.map((review) => review.toMap()).toList(), }; } }
class Review { Review({required this.score, this.review}); final double score; // nullable - assuming the review may be missing final String? review; factory Review.fromMap(Map<String, dynamic> data) { final score = data['score'] as double; final review = data['review'] as String?; return Review(score: score, review: review); } Map<String, dynamic> toMap() { return { 'score': score, if (review != null) 'review': review, }; } }

As we can see, this is a lot of code and this approach doesn't scale if we have many different models.

Model classes with Freezed

To make our life easier, let's use Freezed to define our Restaurant and Review model classes.

Since Restaurant depends on Review, let's start with the Review class:

// review.dart // 1. import freezed_annotation import 'package:freezed_annotation/freezed_annotation.dart'; // 2. add 'part' files part 'review.freezed.dart'; part 'review.g.dart'; // 3. add @freezed annotation @freezed // 4. define a class with a mixin class Review with _$Review { // 5. define a factory constructor factory Review({ // 6. list all the arguments/properties required double score, String? review, // 7. assign it with the `_Review` class constructor }) = _Review; // 8. define another factory constructor to parse from json factory Review.fromJson(Map<String, dynamic> json) => _$ReviewFromJson(json); }

It's very important to use the correct syntax here. If we miss something or add a typo, our code generator will produce some errors.

Let's do the same for the Restaurant class:

// restaurant.dart import 'package:freezed_annotation/freezed_annotation.dart'; // import any other models we depend on import 'review.dart'; part 'restaurant.freezed.dart'; part 'restaurant.g.dart'; @freezed class Restaurant with _$Restaurant { factory Restaurant({ required String name, required String cuisine, // note: using a JsonKey to map our JSON key that uses // *snake_case* to our Dart variable that uses *camelCase* @JsonKey(name: 'year_opened') int? yearOpened, // note: using an empty list as a default value @Default([]) List<Review> reviews, }) = _Restaurant; factory Restaurant.fromJson(Map<String, dynamic> json) => _$RestaurantFromJson(json); }

Note how both the Restaurant and Review classes have a factory constructor listing all the arguments we need, but we haven't declared the corresponding properties.

In fact our code is incomplete and will produce errors like these:

Target of URI doesn't exist: 'restaurant.freezed.dart'. Try creating the file referenced by the URI, or Try using a URI for a file that does exist. The name '_Restaurant' isn't a type and can't be used in a redirected constructor. Try redirecting to a different constructor. The method '_$RestaurantFromJson' isn't defined for the type 'Restaurant'. Try correcting the name to the name of an existing method, or defining a method named '_$RestaurantFromJson'.

Let's take care of this.

Running the code generator

To generate the missing code, we can run this on the console:

flutter pub run build_runner build --delete-conflicting-outputs

This will produce the following output:

[INFO] Generating build script... [INFO] Generating build script completed, took 419ms [INFO] Initializing inputs [INFO] Reading cached asset graph... [INFO] Reading cached asset graph completed, took 55ms [INFO] Checking for updates since last build... [INFO] Checking for updates since last build completed, took 428ms [INFO] Running build... [INFO] 1.3s elapsed, 0/2 actions completed. [INFO] Running build completed, took 2.1s [INFO] Caching finalized dependency graph... [INFO] Caching finalized dependency graph completed, took 27ms [INFO] Succeeded after 2.1s with 5 outputs (5 actions)

And if we look in the project explorer, we can find some new files:

restaurant.dart restaurant.freezed.dart restaurant.g.dart review.dart review.freezed.dart review.g.dart

The .freezed.dart files contain a lot of code. If you want to see all the generated code, you can check this gist.

What's important is that for each model class, the code generator has added:

  • all the stored properties that we need (and made them final)
  • the toString() method
  • the == operator
  • the hashCode getter variable
  • the copyWith() method
  • the toJson() method

Quite handy!

And if we ever need to modify any of the properties in our model classes, we just need to update their factory constructors:

@freezed class Review with _$Review { factory Review({ // update any properties as needed required double score, String? review, }) = _Review; factory Review.fromJson(Map<String, dynamic> json) => _$ReviewFromJson(json); }
@freezed class Restaurant with _$Restaurant { factory Restaurant({ // update any properties as needed required String name, required String cuisine, @JsonKey(name: 'year_opened') int? yearOpened, @Default([]) List<Review> reviews, }) = _Restaurant; factory Restaurant.fromJson(Map<String, dynamic> json) => _$RestaurantFromJson(json); }

Then we can run the code generator again and Freezed will take care of the rest:

flutter pub run build_runner build --delete-conflicting-outputs

Viola! We can now define type-safe, immutable model classes in few lines of code, and generate all the JSON serialization code by running a single command.

Basic JSON annotations

Freezed supports many annotations that let us customize how the code generator processes our models.

The most useful ones are @JsonKey and @Default.

Here's an example of how I'm using them on my Movie App on GitHub:

@freezed class TMDBMovieBasic with _$TMDBMovieBasic { factory TMDBMovieBasic({ @JsonKey(name: 'vote_count') int? voteCount, required int id, @Default(false) bool video, @JsonKey(name: 'vote_average') double? voteAverage, required String title, double? popularity, @JsonKey(name: 'poster_path') required String posterPath, @JsonKey(name: 'original_language') String? originalLanguage, @JsonKey(name: 'original_title') String? originalTitle, @JsonKey(name: 'genre_ids') List<int>? genreIds, @JsonKey(name: 'backdrop_path') String? backdropPath, bool? adult, String? overview, @JsonKey(name: 'release_date') String? releaseDate, }) = _TMDBMovieBasic; factory TMDBMovieBasic.fromJson(Map<String, dynamic> json) => _$TMDBMovieBasicFromJson(json); }

In this case, the keys in the JSON response use a snake_case naming convention, and we can use the @JsonKey annotation to tell Freezed which keys are mapped to which properties.

And we can use the @Default annotation if we want to specify a default value for a given non-nullable property.

Non-nullable arguments need either a required keyword or a default value. If a @Default annotation is specified, its value will be used if the corresponding JSON key-value pair is missing.

Advanced JSON Serialization features with Freezed

What we have covered so far is enough to handle JSON serialization in most cases.

But Freezed is a powerful package and we can do cool things such as:

  • generate union types by specifying multiple constructors
  • specify custom JSON converters

To learn about the most advanced features, read this section on the documentation:

Code Generation Drawbacks

Code generation has some clear benefits and it's the way to go if you have many model classes.

But there are some drawbacks too:

A lot of extra code

Our Restaurant and Review model classes are very simple, but the generated code already takes up 450 lines of code. This quickly adds up if you have many model classes.

Code generation is slow

Code generation in Dart is quite slow.

Even though there are ways to mitigate this, codegen can significantly slow down your development workflow on big projects.

Should generated files be added to git?

If you work in a team and you commit the generated files to git, pull requests become harder to review.

But if you don't, the project is not in a runnable state by default and:

  • every team member needs to remember to run the codegen step (potentially leading to inconsistencies)
  • a custom CI build step is required to build the app

And according to this poll, there isn't even a consensus on whether generated files should be added to git.

Dart Language Limitations

Code generation can help, but it's not a silver bullet.

The underlying problem is that Dart doesn't (yet) have any language features that make JSON serialization easier.

The introduction of data classes - and more broadly static metaprogramming in Dart - has the potential to solve these problems. So we can hope that in the future JSON serialization will become much easier in Dart.

But given the current language limitations, code generation with Freezed is our best option.

VSCode Extension: Json to Dart Model

As we have learned, we can declare some model classes with their factory constructors, and let the code generator take care of everything else.

But wouldn't it be cool if we could generate everything directly from sample JSON documents?

Well, the Json to Dart Model VSCode extension does exactly this.

Having tried it, I can say that it does the job quite well and it supports Freezed under the hood. So if you want to save some extra time, consider using it.

There is also an online tool called QuickType that can generate type-safe model classes in any language. But it doesn't support Dart Null Safety just yet.

Conclusion

We have now explored various options for JSON serialization.

Here are some guidelines to help you choose the best approach:

  • if you have a few, small model classes, you can write the JSON parsing code by hand.
  • if you have many model classes, Freezed can do the heavy-lifting for you.
  • to speed things up even more, consider using the Json to Dart Model extension for VS Code.

Want to get some practice with JSON parsing?

Then you can choose a public free API from this massive list and build a Flutter app with it. As always, practice is the best way to learn. 👍

Happy coding!

Want more?

Fast-track your Flutter learning with over 40 hours of in-depth content.