How to Parse JSON in Dart/Flutter: The Ultimate Guide

Source code on GitHub

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:

  1. write all the JSON parsing code manually
  2. 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 a Map type
  • data contains a name key of type String
  • data contains a cuisine key of type String
  • data contains a year_opened key of type int? (⚠️ 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, while null is used for values that exist but are explicitly set to null. But in Dart, there is no undefined 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 as List<dynamic>?.
  • We use the .map() operator to convert each value to a Review object using Review.fromJson() (a cast to Map<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 object
  • Review.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 a List<Map<String, dynamic>>, as we need to serialize all nested values as well (and not just the Restaurant 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 is square → parse the side field and return a Square object
  • if the type is circle → parse the radius field and return a Circle 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 and toJson 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 the fromJson and toJson 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:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.