Parsing JSON is a very common task for apps that need to fetch data from the Internet.
And depending on how much JSON data you need to process, you have two options:
- write all the JSON parsing code manually
- automate the process with code generation
This guide will focus on how to manually parse JSON to Dart code, including:
- encoding and decoding JSON
- defining type-safe model classes
- parsing JSON to Dart code using a factory constructor
- dealing with nullable and optional values
- data validation with pattern matching (new in Dart 3)
- serializing data back to JSON
- how to debug our code when JSON parsing fails
- advanced use cases (complex/nested JSON, conditional parsing, etc.)
- picking deep values with the deep_pick package
By the end, you'll know how to write robust JSON parsing and validation code that can be used in production.
If you don't want to write all the parsing code by hand and prefer to use code generation, read: How to Parse JSON in Dart/Flutter with Code Generation using Freezed.
We have a lot to cover, so let's start from the basics.
Anatomy of a JSON document
If you have worked with any REST APIs before, this sample JSON response should look familiar:
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
This simple document represents a map of key-value pairs where:
- The keys are strings
- The values can be any primitive type (such as a boolean, number, or string), or a collection (such as a list or map)
Valid JSON data can contain both maps of key-value pairs (using
{}
) and lists (using[]
). These can be combined to create nested collections that represent complex data structures.
In fact, our example includes a list of reviews for a given restaurant:
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
These are stored as a list of maps for the reviews
key, and each review is a valid JSON fragment in itself.
Encoding and Decoding JSON
When a JSON response is sent over the network, the entire payload is encoded as a string.
But inside our Flutter apps, we don't want to extract the data from a string manually:
// a json payload represented as a (multi-line) string
final json = '''
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
''';
Instead, we can decode the JSON first to obtain a map of key-value pairs that can be more easily parsed.
To send JSON data over the network, it first needs to be encoded or serialized. Encoding is the process of turning a data structure into a string. The opposite process is called decoding or deserialization. When you receive a JSON payload as a string, you need to decode or deserialize it before you can use it.
Decoding JSON with dart:convert
For simplicity, let's consider this small JSON payload:
// this represents some response data we get from the network, for example:
// ```
// final response = await http.get(uri);
// final jsonData = response.body
// ```
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
To read the keys and values inside it, we first need to decode it using the dart:convert
package:
// 1. import dart:convert
import 'dart:convert';
// this represents some response data we get from the network
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// 2. decode the json
final parsedJson = jsonDecode(jsonData);
// 3. print the type and value
print('${parsedJson.runtimeType} : $parsedJson');
If we run this code, we get this output:
_InternalLinkedHashMap<String, dynamic> : {name: Pizza da Mario, cuisine: Italian}
In practice, the result type is the same as Map<String, dynamic>
.
_InternalLinkedHashMap is an private implementation of LinkedHashMap, which in turn implements Map.
Note how the values are of type dynamic
. This makes sense because each JSON value could be a primitive type (boolean/number/string), or a collection (list or map).
In fact, jsonDecode
is a generic method that works on any valid JSON payload, regardless of what's inside it. All it does is decode it and return a dynamic
value.
But if we work with dynamic
values in Dart, we lose all the benefits of strong type-safety. A much better approach is to define some custom model classes that represent our response data.
Since Dart is a statically-typed language, it's vital to convert JSON data into model classes that represent real-world objects (such as a recipe, an employee, etc.) and make the most of the type system.
So let's see how to do this.
Parsing JSON to a Dart model class
Given this simple map representing a JSON payload:
{
"name": "Pizza da Mario",
"cuisine": "Italian"
}
We can write a Restaurant
class to represent it:
class Restaurant {
Restaurant({required this.name, required this.cuisine});
final String name;
final String cuisine;
}
As a result, rather than reading the data like this:
parsedJson['name']; // dynamic
parsedJson['cuisine']; // dynamic
We can read it like this:
restaurant.name; // guaranteed to be a non-nullable, immutable String
restaurant.cuisine; // guaranteed to be a non-nullable, immutable String
This way, we can leverage the type system to get compile-time safety and avoid typos and other mistakes.
However, we haven't specified how to convert our parsedJson
to a Restaurant
object yet!
JSON to Dart: Adding a factory constructor
Let's define a factory constructor to take care of this:
factory Restaurant.fromJson(Map<String, dynamic> data) {
// ! there's a problem with this code (see below)
final name = data['name'];
final cuisine = data['cuisine'];
return Restaurant(name: name, cuisine: cuisine);
}
A factory constructor is a good choice for JSON parsing as it lets us do some work (create variables, perform some validation) before returning the result. This is not possible with regular (generative) constructors.
And this is how we can use it:
// type: String
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// type: dynamic (runtime type: _InternalLinkedHashMap<String, dynamic>)
final parsedJson = jsonDecode(jsonData);
// type: Restaurant
final restaurant = Restaurant.fromJson(parsedJson);
Much better. Now the rest of our code can use Restaurant
objects and get all the advantages of strong type-safety in Dart.
But there is a problem. 👇
⚠️ Note about dynamic type casts
Let's look a bit closer at the class we have created:
class Restaurant {
Restaurant({required this.name, required this.cuisine});
final String name; // String
final String cuisine; // String
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name']; // dynamic
final cuisine = data['cuisine']; // dynamic
// implicit cast from dynamic to String
return Restaurant(name: name, cuisine: cuisine);
}
}
As we can see, we have explicitly declared the name
and cuisine
properties as strings.
But since the values of our map are dynamic
, so is the inferred type of the local name
and cuisine
variables. And the static analyzer doesn't stop us from passing them as arguments to the Restaurant
constructor.
This is quite dangerous and will lead to runtime errors that are hard to debug if the JSON values are not of the expected type (String
).
To make our code safer, we can enable stricter type checks by customizing our analysis_options.yaml
file:
analyzer:
language:
strict-raw-types: true
strict-casts: true
As a result, we now get a compiler error:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name']; // dynamic
final cuisine = data['cuisine']; // dynamic
// The argument type 'dynamic' can't be assigned to the parameter type 'String'.
return Restaurant(name: name, cuisine: cuisine);
}
This can be fixed by adding an explicit cast:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'] as String;
final cuisine = data['cuisine'] as String;
return Restaurant(name: name, cuisine: cuisine);
}
Or alternatively, by adding an explicit type check:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'];
final cuisine = data['cuisine'];
if (name is String && cuisine is String) {
return Restaurant(name: name, cuisine: cuisine);
} else {
throw FormatException('Invalid JSON: $data');
}
}
Take away: working with dynamic
variables can be dangerous. By enabling strict-raw-types
and strict-casts
in the analyzer, we are forced to handle them explicitly and write safer code.
Next, let's learn how to deal with optional and nullable values. 👇
JSON to Dart with Null Safety
Sometimes we need to parse some JSON that may or may not have a certain key-value pair.
For example, suppose we have an optional field telling us when a restaurant was first opened:
{
"name": "Ezo Sushi",
"cuisine": "Japanese",
"year_opened": 1990
}
If the year_opened
field is optional, we can represent it with a nullable variable in our model class.
Here's an updated implementation for the Restaurant
class:
class Restaurant {
Restaurant({required this.name, required this.cuisine, this.yearOpened});
final String name; // non-nullable
final String cuisine; // non-nullable
final int? yearOpened; // nullable
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'] as String; // cast as non-nullable String
final cuisine = data['cuisine'] as String; // cast as non-nullable String
final yearOpened = data['year_opened'] as int?; // cast as nullable int
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}
}
As a general rule, we should map optional JSON values to nullable Dart properties. Alternatively, we can use non-nullable Dart properties and provide a default value, like in this example:
// note: all the previous properties have been omitted for simplicity
class Restaurant {
Restaurant({
// 1. required
required this.hasIndoorSeating,
});
// 2. *non-nullable*
final bool hasIndoorSeating;
factory Restaurant.fromJson(Map<String, dynamic> data) {
// 3. cast as *nullable* bool
final hasIndoorSeating = data['has_indoor_seating'] as bool?;
return Restaurant(
// 4. use ?? operator to provide a default value
hasIndoorSeating: hasIndoorSeating ?? true,
);
}
}
Take away: when parsing optional JSON values, you have two options:
- assign them to nullable properties that will be
null
when there is no key-value pair - assign them to non-nullable properties and provide a fallback value when there is no key-value pair, using the if-null operator (
??
)
JSON Data Validation
One benefit of using factory constructors is that we can do some additional validation if needed.
For example, we could write some defensive code that throws a FormatException
if a required value is missing or is not of the correct type:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'];
if (name is! String) {
// will throw if name is missing or not a String
throw FormatException(
'Invalid JSON: required "name" field of type String in $data');
}
final cuisine = data['cuisine'];
if (cuisine is! String) {
// will throw if cuisine is missing or not a String
throw FormatException(
'Invalid JSON: required "cuisine" field of type String in $data');
}
// will throw if the value is neither null or an int
final yearOpened = data['year_opened'] as int?;
// name and cuisine are guaranteed to be non-null if we reach this line
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}
When we validate the JSON data, we should work out for each field:
- Its type (
String
,int
, etc.) - If it's optional or not (nullable vs non-nullable)
- What values are allowed (e.g. age cannot be less than zero)
This will make our JSON parsing code more robust. And we won't have to deal with invalid data in our widget classes because all the validation is done upfront.
However, writing all the conditional validation logic for each field is quite tedious.
And with the introduction of pattern matching in Dart 3, we can explore a possible alternative. 👇
Data Validation with Pattern Matching in Dart 3
As an alternative to the syntax above, we could write this:
factory Restaurant.fromJson(Map<String, dynamic> data) {
if (data
case {
'name': String name,
'cuisine': String cuisine,
'year_opened': int? yearOpened, // ⚠️ warning - see below
}) {
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
} else {
throw FormatException('Invalid JSON: $data');
}
}
By using an if-case statement (new in Dart 3), we only return a Restaurant
object if the case pattern matches the input data. This code validates the following:
data
is aMap
typedata
contains aname
key of typeString
data
contains acuisine
key of typeString
data
contains ayear_opened
key of typeint?
(⚠️ warning, see below)
If the value doesn't match, the code proceeds to the else
branch and throws an exception. Otherwise, it destructures the values of name
, cuisine
and yearOpened
and uses them to return a Restaurant
object.
⚠️ Warning about missing fields
However, consider what happens if we run the code above with this example JSON:
void main() {
final json = {
'name': 'Ezo Sushi',
'cuisine': 'Japanese',
}
final restaurant = Restaurant.fromJson(json); // throws FormatException
}
In this case, we will get a FormatException
because the if-case expects to find a year_opened
field of type int?
, but that field is missing altogether. 🧐
On the other hand, a match will be found if the year_opened
field exists and is null
or contains an int
value:
void main() {
final json = {
'name': 'Ezo Sushi',
'cuisine': 'Japanese',
'year_opened': null,
}
final restaurant = Restaurant.fromJson(json); // ok
}
Take-away: if we use the if-case syntax, we must only match the fields that are always included in the JSON response.
In other words:
- If a JSON field is required (whether it's nullable or not), we can include it in the if-case syntax and cast it to the desired type.
- If a JSON field is optional, we must parse it separately.
factory Restaurant.fromJson(Map<String, dynamic> data) {
if (data
case {
'name': String name,
'cuisine': String cuisine,
}) {
// parse the year_opened field here as it could be missing altogether
final yearOpened = data['year_opened'] as int?;
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
} else {
throw FormatException('Invalid JSON: $data');
}
}
If you're coming from the JavaScript world, the code above may seem overly convoluted. That's because, in JavaScript, the
undefined
keyword is used to represent values that are missing, whilenull
is used for values that exist but are explicitly set tonull
. But in Dart, there is noundefined
keyword, and we have to work around it by other means.
How to debug our code when parsing fails
If you're not careful about how you validate the JSON data, your parsing code may fail if an expected field is missing or is not of the correct type.
And if you're dealing with complex JSON, it can be frustrating to pinpoint exactly which field(s) are causing problems.
For example, consider this (updated) version of the Restaurant
class that uses pattern matching:
class Restaurant {
Restaurant({required this.name, required this.cuisine, this.yearOpened});
final String name;
final String cuisine;
final int? yearOpened;
factory Restaurant.fromJson(Map<String, dynamic> data) {
if (data
case {
'name': String name,
'cuisine': String cuisine,
}) {
final yearOpened = data['year_opened'] as int?;
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
} else {
throw FormatException('Invalid JSON: $data');
}
}
}
And suppose we use it to parse this JSON:
final json = {
"name": "Ezo Sushi",
"reviews": [
{"score": 4.5, "review": "The pizza was amazing!"},
{"score": 5.0, "review": "Very friendly staff, excellent service!"}
]
};
final restaurant = Restaurant.fromJson(json);
print(restaurant);
If we run the code above, we end up with a FormatException
:
Unhandled exception:
FormatException: Invalid JSON: {name: Ezo Sushi, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}
#0 new Restaurant.fromJson (package:json_parsing/main.dart:17:7)
The log tells us the file and line number where the error happens (main.dart:17:7
), and we can easily locate the FormatException
in our code:
factory Restaurant.fromJson(Map<String, dynamic> data) {
if (data
case {
'name': String name,
'cuisine': String cuisine,
}) {
final yearOpened = data['year_opened'] as int?;
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
} else {
throw FormatException('Invalid JSON: $data'); // <-- error happens here
}
}
But the log doesn't tell us which field is invalid! 😞
In this case, the problem is that since the cuisine
field is missing, the if-case statement is refuted, and we end up in the else
branch.
To make life easier, we can make our error-handling code more explicit in the else
branch:
factory Restaurant.fromJson(Map<String, dynamic> data) {
if (data
case {
'name': String name,
'cuisine': String cuisine,
}) {
// happy path
final yearOpened = data['year_opened'] as int?;
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
} else {
// unhappy path - handle errors
if (data['name'] is! String) {
throw FormatException('Invalid JSON: required "name" field of type String in $data');
}
if (data['cuisine'] is! String) {
throw FormatException('Invalid JSON: required "cuisine" field of type String in $data');
}
throw FormatException('Invalid JSON: $data');
}
}
This way, we can better pinpoint the issue in the error log:
FormatException: Invalid JSON: required "cuisine" field of type String in {name: Ezo Sushi, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}
But guess what? We can get rid of the fancy if-case statement and get back to our initial "boring" implementation, which parses each field individually:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'];
if (name is! String) {
// will throw if name is missing or not a String
throw FormatException(
'Invalid JSON: required "name" field of type String in $data');
}
final cuisine = data['cuisine'];
if (cuisine is! String) {
// will throw if cuisine is missing or not a String
throw FormatException(
'Invalid JSON: required "cuisine" field of type String in $data');
}
// * will throw if the value is neither null or an int
final yearOpened = data['year_opened'] as int?;
// thanks to the if statements above, name and cuisine are guaranteed to be non-null here
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}
As a result, when parsing fails, the stack trace will point us directly to the parsing code itself:
final cuisine = data['cuisine'];
if (cuisine is! String) {
throw FormatException( // <-- stack trace will point to this line
'Invalid JSON: required "cuisine" field of type String in $data');
}
Take away: pattern matching seems a promising solution for JSON parsing, but it can make our life harder when parsing fails. Instead, boring code with type checks can be more effective and makes debugging easier. 👍
And if you want your parsing code to be robust (and ready for production), I recommend writing unit tests to ensure all possible edge cases are covered. ✅
JSON Serialization with toJson()
Parsing JSON is useful, but sometimes we also want to convert a model object back to JSON and send it over the network.
To do this, we can define a toJson
method for our Restaurant
class:
Map<String, dynamic> toJson() {
// return a map literal with all the non-null key-value pairs
return {
'name': name,
'cuisine': cuisine,
// here we use collection-if to account for null values
if (yearOpened != null) 'year_opened': yearOpened,
};
}
And we can use this like so:
// given a Restaurant object
final restaurant = Restaurant(name: "Patatas Bravas", cuisine: "Spanish");
// convert it to map
final jsonMap = restaurant.toJson();
// encode it to a JSON string
final encodedJson = jsonEncode(jsonMap);
// then send it as a request body with any networking package
JSON serialization is a simple task since our models have already been pre-validated, and all we need to do is convert them back to a map of key-value pairs.
Advanced Use Cases
Up until this point, we've covered the basics of JSON parsing. But in the real world, you're likely to encounter more complex use cases:
- Parsing nested JSON
- Serializing nested models
- How to parse JSON conditionally depending on a field value
- Dealing with values that can be of multiple types
- Picking deep values
So let's tackle them one by one. 👇
Parsing Nested JSON: List of Maps
JSON can be used to represent both arrays (lists) and objects (maps), and these can be nested to create complex hierarchical structures.
To figure out how to parse complex JSON, we can go back to our initial example:
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
As we have already seen, we can define a Restaurant
class to represent the name
and cuisine
.
And since we want to use model classes and type-safety all the way, let's define a Review
class as well:
class Review {
Review({required this.score, this.review});
// non-nullable - assuming the score field is always present
final double score;
// nullable - assuming the review field is optional
final String? review;
factory Review.fromJson(Map<String, dynamic> data) {
final score = data['score'];
if (score is! double) {
throw FormatException(
'Invalid JSON: required "score" field of type double in $data');
}
final review = data['review'] as String?;
return Review(score: score, review: review);
}
Map<String, dynamic> toJson() {
return {
'score': score,
// here we use collection-if to account for null values
if (review != null) 'review': review,
};
}
}
Then we can update the Restaurant
class to include a list of reviews:
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;
}
And we can also update the factory constructor:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'];
if (name is! String) {
throw FormatException(
'Invalid JSON: required "name" field of type String in $data');
}
final cuisine = data['cuisine'];
if (cuisine is! String) {
throw FormatException(
'Invalid JSON: required "cuisine" field of type String in $data');
}
final yearOpened = data['year_opened'] as int?;
final reviewsData = data['reviews'] as List<dynamic>?;
return Restaurant(
name: name,
cuisine: cuisine,
yearOpened: yearOpened,
reviews: reviewsData != null
? reviewsData
// map each review to a Review object
.map((reviewData) =>
Review.fromJson(reviewData as Map<String, dynamic>))
.toList() // map() returns an Iterable so we convert it to a List
: <Review>[], // use an empty list as fallback value
);
}
A few notes:
- The
reviews
key may be missing, so we parse it separately outside the if-case statement. - The
reviews
value is a list, so we cast it asList<dynamic>?
. - We use the
.map()
operator to convert each value to aReview
object usingReview.fromJson()
(a cast toMap<String, dynamic>
is also needed). - If the reviews are missing, we use an empty list (
<Review>[]
) as a fallback.
Note that each class is responsible for parsing its own fields:
Restaurant.fromJson
parses the fields of the top-level JSON objectReview.fromJson
parses the reviews inside the list
Additionally, each class will throw a FormatException
if validation fails. As a result, it becomes much easier to pinpoint which fields are invalid, especially when you're dealing with complex or deeply nested JSON objects.
In the example above, we made some assumptions about what may or may not be
null
, what fallback values to use, etc. You need to write the parsing code that is most appropriate for your use case based on the specification of the APIs you're consuming.
Serializing Nested Models
As a last step, here's the toJson
method to convert a Restaurant
(and all its reviews) back into a Map
:
Map<String, dynamic> toJson() {
return {
'name': name,
'cuisine': cuisine,
if (yearOpened != null) 'year_opened': yearOpened,
'reviews': reviews.map((review) => review.toJson()).toList(),
};
}
Note how we convert the
List<Review>
back to aList<Map<String, dynamic>>
, as we need to serialize all nested values as well (and not just theRestaurant
class itself). If you have multiple levels of nesting, you should map and serialize collections at each level.
To test the code above, we can create a Restaurant
object and convert it back into a map that can be encoded and printed or sent over the network:
final restaurant = Restaurant(
name: 'Pizza da Mario',
cuisine: 'Italian',
reviews: [
Review(score: 4.5, review: 'The pizza was amazing!'),
Review(score: 5.0, review: 'Very friendly staff, excellent service!'),
],
);
final encoded = jsonEncode(restaurant.toJson());
print(encoded);
// output: {"name":"Pizza da Mario","cuisine":"Italian","reviews":[{"score":4.5,"review":"The pizza was amazing!"},{"score":5.0,"review":"Very friendly staff, excellent service!"}]}
How to parse JSON conditionally depending on a field value
Suppose you have a class hierarchy that is used to define different kinds of shapes:
sealed class Shape {
const Shape();
double get area;
}
class Square extends Shape {
const Square(this.side);
final double side;
@override
double get area => side * side;
}
class Circle extends Shape {
const Circle(this.radius);
final double radius;
@override
double get area => pi * radius * radius;
}
And suppose the JSON used to represent these shapes looks like this:
const shapesJson = [
{
'type': 'square',
'side': 10.0,
},
{
'type': 'circle',
'radius': 5.0,
},
];
Here's what we want to do in this scenario:
- if the
type
issquare
→ parse theside
field and return aSquare
object - if the
type
iscircle
→ parse theradius
field and return aCircle
object - if the required values are missing or the type is not recognized → throw an exception
The easiest way to handle this is to use a switch expression (new in Dart 3):
factory Shape.fromJson(Map<String, dynamic> json) {
return switch (json) {
{'type': 'square', 'side': double side} => Square(side),
{'type': 'circle', 'radius': double radius} => Circle(radius),
_ => throw FormatException('Invalid JSON: $json'),
};
}
The code above is very concise, and we can use it like this:
const shapesJson = [
{
'type': 'square',
'side': 10.0,
},
{
'type': 'circle',
'radius': 5.0,
},
];
for (final json in shapesJson) {
final shape = Shape.fromJson(json);
print('${shape.runtimeType} area: ${shape.area}');
}
Here's the output:
Square area: 100.0
Circle area: 78.53981633974483
But what happens if we omit one of the required fields?
const shapesJson = [
{
'type': 'square',
//'side': 10.0, // omitted intentionally
},
{
'type': 'circle',
'radius': 5.0,
},
];
for (final json in shapesJson) {
final shape = Shape.fromJson(json);
print('${shape.runtimeType} area: ${shape.area}');
}
Here's the output in this case:
Unhandled exception:
FormatException: Invalid JSON: {type: square}
#0 new Shape.fromJson (package:json_parsing/shape.dart:11:12)
The error is quite vague and doesn't help us pinpoint the issue.
But we can fix that by adding some more cases to our switch:
factory Shape.fromJson(Map<String, dynamic> json) {
return switch (json) {
// valid square
{'type': 'square', 'side': double side} => Square(side),
// invalid square
{'type': 'square'} => throw FormatException(
'Invalid JSON: required "side" field of type double in $json'),
// valid circle
{'type': 'circle', 'radius': double radius} => Circle(radius),
// invalid circle
{'type': 'circle'} => throw FormatException(
'Invalid JSON: required "radius" field of type double in $json'),
// invalid type
{'type': String type} => throw FormatException(
'Invalid JSON: shape $type is not recognized in $json'),
// invalid JSON
_ => throw FormatException('Invalid JSON: $json'),
};
}
As a result, the output log is much more helpful:
Unhandled exception:
FormatException: Invalid JSON: required "side" field of type double in {type: square}
#0 new Shape.fromJson (package:json_parsing/shape.dart:12:29)
Take away: we can use switch expressions to return different objects depending on the input data. And we can also handle parsing errors, making it easy to debug our code when the JSON is invalid.
Note: Conditional JSON parsing logic can be written with if/else statements or the switch syntax as shown above. Another alternative is to use the Freezed package, which can be configured to generate the JSON parsing code using json_serializable. For more info, read: FromJson/ToJson.
Dealing with values that can be of multiple types
Sometimes, we have to deal with APIs that don't follow a specific JSON schema and return inconsistent results.
This is a problem that should be fixed on the backend, but we don't always have control over that.
To work around it, we can create a helper function that checks the type of the value and converts it to the type we want. Example:
// call this from the factory constructor
String _parseValue(Map<String, dynamic> json) {
final value = json['someValue'];
if (value is int) {
return value.toString();
} else if (value is String) {
return value;
} else {
throw FormatException('Invalid JSON: "someValue" should be an int or String, but ${value.runtimeType} was found inside $json');
}
}
Picking Deep Values with the deep_pick package
Parsing a whole JSON document into type-safe model classes is a very common use case.
But sometimes, we just want to read some specific values that may be deeply nested.
Let's consider our sample JSON once again:
final jsonData = '''
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
''';
If we wanted only to get the score for the first review, we could do it like this:
final decodedJson = jsonDecode(jsonData); // dynamic
final score = decodedJson['reviews'][0]['score'] as double;
If we have disabled the strict-raw-types
and strict-casts
settings inside analysis_options.yaml
(not recommended), Dart will let us apply multiple subscript operators ([]
) to the decodedJson
variable.
But the code above is neither null safe nor type safe, and we have to explicitly cast the parsed value to the type we want (double
).
To improve this, we can use the deep_pick package, which lets us simplify JSON parsing with a type-safe API.
Once installed, we can use it to get the value we want without any manual casts:
import 'dart:convert';
import 'package:deep_pick/deep_pick.dart';
final decodedJson = jsonDecode(jsonData); // dynamic
final score = pick(
decodedJson, // input
'reviews', // first level (map)
0, // second level (list)
'score', // third level (map)
).asDoubleOrThrow();
deep_pick offers a variety of flexible APIs that we can use to parse primitive types, lists, maps, DateTime objects, and more. Read the documentation for more info.
Adding the toString and equality methods with Equatable
When working with model classes, we should override the toString
method from the Object
class, so we can easily print the fields inside our object.
And we should also override the ==
operator and hashCode
getter so we can compare objects.
This can be tedious and error-prone. But thanks to the Equatable package, it doesn't have to be.
In fact, this is just a case of extending the Equatable
class and using the Quick Fix option to add the missing overrides:
import 'package:equatable/equatable.dart';
class Restaurant extends Equatable {
final String name;
final String cuisine;
final int? yearOpened;
final List<Review> reviews;
...
@override
List<Object?> get props => [name, cuisine, yearOpened, reviews];
@override
bool? get stringify => true;
}
// We can do the same for the Review class too.
As a result, we can print our restaurant directly like this:
print(restaurant);
// output: Restaurant(Pizza da Mario, Italian, null, [Review(4.5, The pizza was amazing!), Review(5.0, Very friendly staff, excellent service!)])
Adding equality methods to your model classes makes it easier to write unit tests that need to compare objects. And it is also a requirement when working with state management packages such as Bloc and Riverpod.
Note about performance
When you parse small JSON documents, your application is likely to remain responsive and not experience performance problems.
But parsing very large JSON documents can result in expensive computations that are best done in the background on a separate Dart isolate. The official docs have a good guide about this:
Conclusion
JSON serialization is a very mundane task. But if we want our apps to be robust and easy to debug, we must pay attention to the details:
- Create model classes with
fromJson
andtoJson
methods for all domain-specific JSON objects in your app. - Add your validation logic inside
fromJson
to make the parsing code more robust (with a combination of pattern matching, explicit casts, and type checks). - For nested JSON data (lists of maps), use the
map
operator and apply thefromJson
andtoJson
methods as needed. - Consider using the deep_pick package to parse JSON in a type-safe way.
If you're dealing with complex JSON (many properties, multiple levels of nesting), I also recommend the following:
- Ensure that each model class includes type checks for each field.
- When you throw exceptions, make it clear what went wrong and why.
- Install a crash reporting solution like Crashlytics or Sentry so that you can catch errors in production.
Source Code
All the examples presented in this guide can be found in this repo:
Additional Resources
If you have many different model classes, or each class has many properties, writing all the parsing code by hand becomes time-consuming and error-prone.
In such cases, code generation is a viable alternative, and this article explains how to use it:
And if you need to parse large JSON data, you should do so in a separate isolate for best performance. This article covers all the details:
Flutter Foundations Course Now Available
I launched a brand new course where you will learn about state management, app architecture, navigation, testing, and much more: