Flutter: Customizing Serialization and Deserialization with json_serializable

Exploring Constructor Parameters in json_serializable for Custom Serialization and Deserialization.
Apr 24 2023 · 9 min read

Background

When it comes to working with JSON data in Flutter, there are two primary approaches: Manual serialization and Automated serialization.

Manual serialization requires us to manually convert our Dart objects to JSON and vice versa. This can be time-consuming and error-prone, especially when dealing with complex data structures.

Automated Serialization, on the other hand, is a much more efficient and less error-prone approach.

In this article, we will dive deep into the various options available in the JsonSerializable constructor and how we can use them to customize the serialization and deserialization of our JSON data.

Positive thinking leads to positive outcomes. Try out Justly, build good habits, and start thinking positively today!

Introduction

The json_serializable package is a code generation library for Dart that simplifies the process of serializing and deserializing JSON data. With json_serializable, you can annotate your model classes with the json_annotation package, which tells the library which classes to generate serialization and deserialization code for.

To generate the code, you simply need to run the code generator using the build_runner package. This generates the Dart files necessary for the serialization and deserialization process.

Getting Started!

Add the dependencies

In Pubspec.yaml add the following dependencies.

dependencies:
  flutter:
    sdk: flutter
  json_annotation: ^4.8.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  json_serializable: 
  build_runner:

We added json_annotation as a development dependency and json_serializable and build_runner packages in dev-dependencies as these packages are only used during development to generate the serialization code.

Set up

To implement automatic serialization and deserialization in Dart using the json_serialiazable package, we need to set up our model class and generate a serialization code.

First, add the necessary imports and annotations at the beginning of your dart file.

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

@JsonSerializable()
class Movie{
  final String name;
  final DateTime dateOfRelease;

  Movie({
    required this.name,
    required this.dateOfRelease,
});

  factory Movie.fomJson(Map<String,dynamic> json)=>_$MovieFromJson(json);

  Map<String, dynamic> toJson(Movie movie)=>_$MovieToJson(this);
}

The @JsonSerializable() annotation is used to indicate that the Movie class should have a serialization code generated for it. Note that we also include part directives to indicate that this file is part of a generated file.

In addition, we need to define two methods: fomJson and toJson. These methods are used for serialization and deserialization, respectively.

To generate the serialization code, we need to run the build_runner command in the terminal: dart run build_runner build . This command generates the necessary serialization code and saves it to a new file with the name <class_name>.g.dart. After running the command, you’ll see a new file named movie.g.dart generated next to the movie.dart file.

After making any changes to the json_serializable annotations, it is essential to run the code generator to generate the updated serialization and deserialization code.

Let's explore the JsonSerializable constructor parameters.

const JsonSerializable({
  this.anyMap,
  this.checked,
  this.constructor,
  this.createFieldMap,
  this.createFactory,
  this.createToJson,
  this.disallowUnrecognizedKeys,
  this.explicitToJson,
  this.fieldRename,
  this.ignoreUnannotated,
  this.includeIfNull,
  this.converters,
  this.genericArgumentFactories,
  this.createPerFieldToJson,
});

anyMap

The anyMap parameter of the @JsonSerializable annotation is used to handle JSON objects that contain Map types with non-String keys. By default, anyMap is set to false, which means that Map types are expected to have String keys. However, in situations where JSON data may contain maps with different key-value types, we can set anyMap to true.

For example, let’s say we have a Map movieReview like this,

final data={
  'name':'XXX',
  'dateOfRelease':DateTime.now().toString(),
  'movieReview':
    {
       1:50,
      'review':'YYY'
    }
};

It will throw Unhandled Exception: type ‘_Map<Object, Object>’ is not a subtype of type ‘Map<String, dynamic>’ in type cast.

To deserialize JSON objects with maps that have non-string keys, we can set anyMap to true.

@JsonSerializable(anyMap: true)
class Movie {
...
 final Map movieReview;
 
  const Movie({
...
    required this.movieReview
});

  factory Movie.fomJson(Map<String,dynamic json)=>_$MovieFromJson(json);

  Map<String, dynamic> toJson()=>_$MovieToJson(this);
}

It is important to note that if anyMap is set to true, the generated serialization method will use Map<dynamic, dynamic> to serialize an object, which may not be ideal in some cases. In those situations, you can consider writing a custom toJSon method that explicitly converts the anyMap to a Map<String, dynamic>.

checked

By default, checked is set to false. By setting checked to true, the generated code will validate the input JSON data matches the expected data types, and if there’s a mismatch, it will throw a CheckedFromJsonException with the details of the error.

When we try to parse a JSON map that has a dateOfRelease value of type int instead of DateTime,

var movieData = {
  'name': 'XXX',
  'dateOfRelease': 2
};
Movie movie = Movie.fromJson(movieData);

It will throw a CheckedFromJsonException with the following message,

Unhandled Exception: CheckedFromJsonException
Could not create `Movie`.
There is a problem with "dateOfRelease".
type 'int' is not a subtype of type 'String' in type cast

Isn’t that cool? It is generally a good idea to set the checked to true during development to catch any potential errors early, and then set it to false in production for better performance. No more hunting-down type errors during runtime!

constructor

The constructor parameter allows us to specify a named constructor to target when creating the fromJSon function. If a named constructor is specified, the fromJSon function will use that instead of the default constructor.

Let’s say we have a Movie class with a private constructor,

@JsonSerializable(constructor: '_')
class Movie {
  final String name;
  final DateTime dateOfRelease;

   Movie({
    required this.name,
    required this.dateOfRelease,
});

  Movie._(this.name, this.dateOfRelease);

  factory  Movie.fromJson(Map<String,dynamic> json)=>_$MovieFromJson(json);

  Map<String, dynamic> toJson()=>_$MovieToJson(this);
}

In this case, the private constructor _ is used to create a new instance of the Movie class from the JSON data instead of the default constructor. This can be useful when you need to perform some additional logic or validation before creating an object from the JSON data.

createFactory

The default value is true, and a private, static method called _$MovieFromJson is generated in the part file. This is responsible for creating a new instance of the class from a JSON Map.

However, there may be cases where we want to perform some additional validation on the input data before creating the instance of the class, In such cases, we can set createFactory to false and write down our own factory constructor.

Let’s add some validation for Movie class.

@JsonSerializable(createFactory: false)
class Movie {
  final String name;
  final DateTime dateOfRelease;

  Movie({
    required this.name,
    required this.dateOfRelease,
});

  factory Movie.fromJson(Map<String, dynamic> json) {
    if (json['name'] == '') {
      throw ArgumentError('Name can\'t be a empty String');
    }
    return Movie(name: json['name'], dateOfRelease: json['dateOfRelease']);
  }

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

Disabling createFactory can be a good option if you need more fine-grained control over the deserialization process. However, keep in mind that you will need to write your own factory constructor, which may require extra work.

createFieldMap

By default, its value is set as false, When set to true, it generates a constant map that maps the class field names to the corresponding JSON keys. For instance, if we set createFieldMap to true for our Movie class, the generated file has the following method,

const _$MovieFieldMap = <String, String>{
  'name': 'name',
  'dateOfRelease': 'dateOfRelease',
};

This constant map is useful when using other code generators to provide features such as field renaming. However, it’s not necessary for basic serialization and deserialization using the generated methods.

createPerFieldToJson

Its default value is false. If set to true, it generates a private, static abstract class in the generated part file. This class contains static methods that correspond to each field in the annotated class, and are responsible for encoding only that field into JSON.

It generates _$MoviePerFieldToJson for Movie class like this,

// ignore: unused_element
abstract class _$MoviePerFieldToJson {
  // ignore: unused_element
  static Object? name(String instance) => instance;
  // ignore: unused_element
  static Object? dateOfRelease(DateTime instance) => instance.toIso8601String();
}

We can use this method to encode only a single property of the Movie object like this,

final nameJson = _$MoviePerFieldToJson.name('XXX');

This can be especially useful if you have a large object with many fields, but only need to encode a subset of those fields at a time.

createToJson

By default, the value of createToJson is true and is used to specify whether a top-level function should be created to generate a JSON representation of the object. However, you can set it to false, if you don’t want the library to generate the corresponding function in the generated file.

Let’s set it to false for Movie class

part of 'movie.dart';

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

Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
      name: json['name'] as String,
      dateOfRelease: DateTime.parse(json['dateOfRelease'] as String),
    );

When createToJson is set to false, the library won’t generate the _$MovieToJson function in the generated file.

disAllowUnrecognisedKeys

By default, this option is set to false, which means that any unrecognized keys in the JSON map will simply be ignored by the generated fromJson method. This is often useful when working with APIs that may send additional data that your application does not need to use.

However, if set to true, any unrecognized keys in the JSON map will cause an UnrecognizedKeysException to be thrown.

Let’s set disAllowUnrecognisedKeys to true for Movie class and try to deserialize JSON data that contains an extra director field like this,

final data={
  'name':'XXX',
  'dateOfRelease':DateTime.now().toString(),
  'director':'RobertoS'
};

Movie movie= Movie.fromJson(data);

It will throw UnrecognizedKeysException,

Unhandled Exception: UnrecognizedKeysException: Unrecognized keys: [director]; supported keys: [name, dateOfRelease]

This can be useful in cases where you want to ensure that the data being deserialized adheres strictly to a defined schema.

explicitToJson

By default, this option is set to false, which means that generated toJson method will omit the toJson call when encoding nested objects.

If we have Movie class with a nested object Review and want to ensure that toJson method of Review is always called when encoding a Movie, the explicitToJson can be set as true.

fieldRename

fieldRename determines how class field names are automatically converted to JSON map keys. By default, if no value is provided, the FielsRename.none is used, which means that the name of the field is used without modification.

When we want to store keys in a snake case, we can set fieldRename to FieldRename.snake.

@JsonSerializable(fieldRename: FieldRename.snake)
class Movie {
....
}

//generated file

Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
      name: json['name'] as String,
      dateOfRelease: DateTime.parse(json['date_of_release'] as String),
    );

Map<String, dynamic> _$MovieToJson(Movie instance) => <String, dynamic>{
      'name': instance.name,
      'date_of_release': instance.dateOfRelease.toIso8601String(),
    };

The generated JSON Keys are in the snake case. Other available options include FieldRename.snake, FieldRename.kebab, FieldRename.pascal, and FieldRename.screamingSnake.

ignoreUnannotated

By default, its value is set to false, which means that all fields in your class will have code generated. But if we set it to true, only the fields that are explicitly annotated with JsonKey will have code generated, and the Unannotated fields will be ignored.

If we only want to include the name field in the JSON code,

@JsonSerializable(ignoreUnannotated: true)
class Movie {
  @JsonKey()
  final String name;
  final DateTime? dateOfRelease;
....
}

And just like that, the dateOfRelease field will be ignored when generating JSON code,

Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
      name: json['name'] as String,
    );

Map<String, dynamic> _$MovieToJson(Movie instance) => <String, dynamic>{
      'name': instance.name,
    };

Great!! 👍. It saves you from the hassle of having to add JSonKey(includeToJson: false, includeFromJson: false) to all of the unneeded fields.

includeIfNull

By default, its value is set to true, which means all fields, even if they are null, will be included in the serialized output. But If we set it to false, then any nullable fields with a value of null will be omitted from the serialized output.

If we deserialize a JSON map with a null value for dateOfRelease.

final data={
  'name':'XXX',
  'dateOfRelease':null,
};

Movie movie= Movie.fromJson(data);
print(movie.toJson());

//Output
{name: XXX,dateOfRelease: null}

And if we set includeIfNull to false, then the dateOfRelease will be omitted.

final data={
  'name':'XXX',
  'dateOfRelease':null,
};

Movie movie= Movie.fromJson(data);
print(movie.toJson());

//Output
{name: XXX}

So, what do you think? Do you want all the fields to be included in the JSON output even if they are null or would you prefer a more concise output that only includes non-null values? Yes, it is useful if you want to be more selective and leave some things behind to make your output cleaner and more organized.

converters

What if you want to convert dateOfRealease to and from int representation to DateTime? In this scenario, you can use converters property to specify a list of JsoConverters objects. JsonConverter is convenient if you want to use the same conversion logic on many fields.

Let’s create a custom DateConverter class that implements the JSonConverter to convert DateTime object and an int representation of the date.

@JsonSerializable(converters: [DateConverter()])
class Movie {
 ....
}

class DateConverter implements JsonConverter<DateTime,int>{
  const DateConverter();
  @override
  DateTime fromJson(int json) {
  return DateUtils.dateOnly(DateTime.fromMillisecondsSinceEpoch(json));
  }

  @override
  int toJson(DateTime dateTime) {
  return DateUtils.dateOnly(dateTime).millisecondsSinceEpoch;
  }
}

Okay, Let’s test it.

void main() {
final data={
  'name':'Sultan',
  'dateOfRelease':123,
};

Movie movie= Movie.fromJson(data);
print(movie.dateOfRelease);
}

//Output:
1970-01-01 00:00:00.000

The main benefit of using Jsonconverters is that converters allow for the reusability of the same conversion logic across multiple fields or classes.

genericArgumentFactories

The genericArgumentFactories is a helpful little guy that generates extra parameters for fromJson and toJson to support serializing values of generic types.

To enable this option, simply set it to true.

@JsonSerializable(genericArgumentFactories: true)
class Movie<T> {

  final DateTime dateOfRelease;
  final T value;

  Movie({
    required this.value,
    required this.dateOfRelease,
  });

  factory Movie.fromJson(Map<String, dynamic> json, T Function(Object? json) fromJsonT) => _$MovieFromJson(json,fromJsonT);

  Map<String, dynamic> toJson(Movie<T> movie,Object Function(T value) toJsonT) => _$MovieToJson(movie,toJsonT);
}

It generates the additional helper parameter FromJsonT for the fromJson and toJsonT for the toJson method. This helper parameter allows us to deserialize and serialize generic types.

void main() {
final data={
  'value':'XXX',
  'dateOfRelease':DateTime.now().toString(),
};

Movie movie= Movie.fromJson(data,(json)=>json as String);
}

And that’s a wrap!

Conclusion

In this article, we have covered the basics of how to use json_serializable, including the use-case of each parameter of JsonSerializable annotation and how it can be useful to customize serialization and deserialization.

I hope you found this article helpful in understanding how to use json_serializable effectively.

Thank you for reading, Happy coding! 👋

Useful Articles


sneha-s image
Sneha Sanghani
Flutter developer | Writing a Blog on Flutter development


sneha-s image
Sneha Sanghani
Flutter developer | Writing a Blog on Flutter development

canopas-logo
We build products that customers can't help but love!
Get in touch
contact-footer
Say Hello!
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.