JSON & Serialization #
Almost all data exchanged between your Flutter app and API servers is sent in JSON (JavaScript Object Notation) format. When a Flutter app receives JSON data from the internet, that data is still raw text strings without safe data types (untyped strings). To use that data safely in your UI code without worrying about typos or mismatched data types, you must perform data conversion.
This data conversion process is divided into two directions:
- Deserialization (JSON Parsing): Converting JSON text strings from the API into type-safe Dart class object instances.
- Serialization: Converting Dart class object instances back into Map structures which are then encoded into raw JSON text strings to be sent as request bodies to the API server.
In the Flutter ecosystem, you can choose various levels of automation for this work. From writing conversion functions manually, leveraging automatic code generation-based libraries, to using modern immutable data models ready for production scale.
Here’s a data pipeline flow diagram illustrating how raw JSON data transforms into ready-to-use objects in your UI:
graph TD
classDef default stroke:#333,stroke-width:2px;
A["Raw JSON (String from API/Internet)"] -->|"jsonDecode()"| B["Dynamic Map (Map String, dynamic)"]
B -->|"factory Model.fromJson()"| C["Model Object Instance (Type-Safe Object)"]
C -->|"State Management / UI"| D["Visual Screen Display (Widget)"]
C -->|"Model.toJson()"| E["Dynamic Map (Map String, dynamic) for Request"]
E -->|"jsonEncode()"| F["Raw JSON String for Request Body"]
F -->|"Send to API / Internet"| G["Backend API Server"]Approach 1: Manual Parsing with dart:convert #
The most basic approach is writing conversion functions manually without any external library help. You use Dart SDK’s built-in jsonDecode() method from the dart:convert package to turn text strings into Map<String, dynamic> objects, then manually map every key to your Dart class properties.
Manual Model Implementation Example #
Let’s create a Product data model class by writing the fromJson deserialization and toJson serialization functions manually:
import 'dart:convert';
class Product {
final String id;
final String name;
final double price;
final bool available;
final List<String> categories;
const Product({
required this.id,
required this.name,
required this.price,
required this.available,
required this.categories,
});
// 1. DESERIALIZATION: Converting the decoded Map into a Product object
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
// APIs sometimes send the price as an integer or double,
// casting to 'num' first then converting to double is highly recommended
price: (json['price'] as num).toDouble(),
available: json['available'] as bool,
categories: (json['categories'] as List<dynamic>)
.map((item) => item as String)
.toList(),
);
}
// 2. SERIALIZATION: Converting object properties into a Map to send to the API
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
'available': available,
'categories': categories,
};
}
}
// === How to Use It in Your App Code ===
// A. Deserializing a single object
final String singleResponseBody = '{"id":"1","name":"Civet Coffee","price":45000.0,"available":true,"categories":["beverage"]}';
final Map<String, dynamic> singleMap = jsonDecode(singleResponseBody);
final Product coffeeProduct = Product.fromJson(singleMap);
// B. Deserializing a list of objects (List)
final String listResponseBody = '[{"id":"1","name":"Civet Coffee","price":45000.0,"available":true,"categories":["beverage"]}]';
final List<dynamic> rawList = jsonDecode(listResponseBody);
final List<Product> productList = rawList.map((item) => Product.fromJson(item as Map<String, dynamic>)).toList();
The Fatal Limitations of Manual Writing #
Although writing code manually is satisfying because you have full control without additional dependencies, this pattern has very fatal weaknesses when your app starts growing:
- Very Repetitive & Tedious: You have to write identical
fromJsonandtoJsonboilerplate code for dozens of data model classes. - Prone to Typos: If the server changes a key name from
"name"to something else, you must manually change that string in your code. There’s no compile-time check protection for typos in map strings. - No Value Comparison by Default: In Dart, two class object instances with exactly the same field values are considered different by the
==operator if their memory addresses differ. You have to write==andhashCodeoverride methods manually for every class. - copyWith Boilerplate: You have to write the
copyWithfunction yourself if you want to modify immutable data safely.
Approach 2: json_serializable for Targeted Automation #
To free yourself from repetitive manual conversion code, you can use the json_serializable library. This library uses the code generation concept: you just add simple annotations to your model classes, and the generator automatically writes a .g.dart helper file handling the JSON conversion logic behind the scenes.
Dependency Configuration #
Add the following dependencies to your project’s pubspec.yaml file:
dependencies:
# Provides the @JsonSerializable annotation
json_annotation: ^4.9.0
dev_dependencies:
# Runs the code generator engine in Dart
build_runner: ^2.4.13
# Code generator specific to json_serializable
json_serializable: ^6.8.0
Writing Models with Annotations #
// product.dart
import 'package:json_annotation/json_annotation.dart';
// 1. Declare the part file that will be auto-generated later
part 'product.g.dart';
// 2. Give the @JsonSerializable annotation
@JsonSerializable()
class Product {
final String id;
final String name;
final double price;
final bool available;
final List<String> categories;
// Renaming JSON keys that don't fit Dart's camelCase convention
@JsonKey(name: 'created_at')
final DateTime createdAt;
// Providing a default value if the server doesn't send that field
@JsonKey(defaultValue: false)
final bool isDiscounted;
const Product({
required this.id,
required this.name,
required this.price,
required this.available,
required this.categories,
required this.createdAt,
this.isDiscounted = false,
});
// 3. Connect the factory constructor to the auto-generated function
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
// 4. Connect the toJson method to the auto-generated function
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
Running the build_runner Generator #
When the file above is created, Flutter will show error messages because the product.g.dart file doesn’t exist yet. You have to trigger the generator using the terminal:
# Run the generator once to create the *.g.dart files
flutter pub run build_runner build --delete-conflicting-outputs
During active development, run the generator in watch mode so it monitors file changes in real-time:
# The generator will automatically update files every time you press save
flutter pub run build_runner watch --delete-conflicting-outputs
Approach 3: Freezed — The Most Complete Immutable Class #
Although json_serializable has solved the fromJson and toJson writing problem, you still have to write the copyWith method, toString for debug logging, and == operator overrides manually if you want to apply clean state management patterns.
The freezed library comes as the most complete solution combining all advanced immutable class features with the automatic JSON serialization system from json_serializable.
Dependency Configuration #
Add the following dependencies to your pubspec.yaml file:
dependencies:
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.13
freezed: ^2.5.7
json_serializable: ^6.8.0
Implementing Model Classes with Freezed #
Writing models with Freezed uses slightly different syntax because it leverages the generated mixin feature:
// product.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
part 'product.g.dart'; // Still needs the g.dart file from json_serializable
@freezed
class Product with _$Product {
// Freezed requires defining the main factory constructor
const factory Product({
required String id,
required String name,
required double price,
@Default(true) bool available, // Use @Default for default initialization
@Default([]) List<String> categories,
@JsonKey(name: 'created_at') required DateTime createdAt,
}) = _Product;
// Connect to the JSON deserialization system
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}
After running the build_runner build command, Freezed will automatically generate helper files containing:
- The
copyWith()function: Makes it easy to create object copies by changing some fields safely with type checking.final cheapCoffee = coffeeProduct.copyWith(price: 20000.0); - The
==andhashCodeoperator overrides: Two objects with the same field contents will automatically be evaluated as equal (true) by the compiler. - The
toString()implementation: Makes it easy to read object property contents when printing them in debug logs.
Handling Nested Objects Recursively #
Real-world apps often receive complex JSON data structures with objects inside other objects (nested objects). For example, an Order data object containing a Customer object and a list of ShoppingItem objects.
To handle nested objects with generator libraries, you must ensure all child classes are also configured with automatic serialization. Additionally, there’s one crucial rule you must not skip.
The explicitToJson: true Rule #
By default, the json_serializable generator only calls the .toJson() function at the outermost level. If your main class contains nested objects, the generator will produce a Map containing child object instances, not Maps within Maps. This will trigger a crash error when you try to do jsonEncode().
You must configure the generator to trigger the .toJson() function recursively to all its children. You can add it at the class level, or configure it globally at the project root using a build.yaml file.
Here’s an example of writing a nested model file structure using Freezed:
// 1. Customer Model
@freezed
class Customer with _$Customer {
const factory Customer({
required String id,
required String name,
required String email,
}) = _Customer;
factory Customer.fromJson(Map<String, dynamic> json) => _$CustomerFromJson(json);
}
// 2. ShoppingItem Model
@freezed
class ShoppingItem with _$ShoppingItem {
const factory ShoppingItem({
required String productId,
required String productName,
required double price,
required int quantity,
}) = _ShoppingItem;
factory ShoppingItem.fromJson(Map<String, dynamic> json) => _$ShoppingItemFromJson(json);
}
// 3. Main Order Model (Contains nested objects & object lists)
@Freezed(toJson: true) // Forces the generator to include recursive toJson functions
class Order with _$Order {
const factory Order({
required String orderId,
required Customer customer, // Nested object
required List<ShoppingItem> shoppingList, // Nested object list
required DateTime transactionDate,
}) = _Order;
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
}
Union Types for Managing Network State #
One of the most advanced features provided by Freezed is Union Types (often called Sealed Classes). This feature allows you to create a single parent class representing several variations of mutually exclusive child class forms.
This concept is ideal for representing the data status of your network API call results in the UI layer:
// api_result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'api_result.freezed.dart';
@freezed
class ApiResult<T> with _$ApiResult<T> {
const factory ApiResult.loading() = _Loading;
const factory ApiResult.success(T data) = _Success<T>;
const factory ApiResult.failure(String errorMessage) = _Failure<T>;
}
Consuming Union Types in the Flutter UI #
You can leverage the when pattern matching method to safely parse data status inside your widget build methods:
class ProductListWidget extends StatelessWidget {
final ApiResult<List<Product>> state;
const ProductListWidget({super.key, required this.state});
@override
Widget build(BuildContext context) {
return state.when(
loading: () => const Center(child: CircularProgressIndicator()),
success: (productList) {
return ListView.builder(
itemCount: productList.length,
itemBuilder: (context, index) => ListTile(title: Text(productList[index].name)),
);
},
failure: (errorMessage) => Center(
child: Text('An error occurred: $errorMessage', style: const TextStyle(color: Colors.red)),
),
);
}
}
Pattern matching forces you to explicitly handle all three status types at compile time, preventing you from forgetting to draw the loading screen or handle the error screen.
The Generic API Response Wrapper Pattern #
When communicating with industry-standard REST APIs, the backend server usually always returns responses in a uniform envelope response format for all endpoints, with only the contents of the data column changing dynamically.
Example of an API wrapper response format:
{
"success": true,
"message": "Data fetched successfully",
"data": {
"id": "1",
"name": "Office Chair"
}
}
Writing a separate response class for every data type (e.g., UserResponse, ProductResponse, OrderResponse) will create incredible code redundancy. You can solve this problem using a generic class pattern with the genericArgumentFactories: true annotation parameter on the json_serializable library.
Here’s an example of creating a re-usable generic wrapper:
import 'package:json_annotation/json_annotation.dart';
part 'api_response.g.dart';
// Generic argument factory configuration
@JsonSerializable(genericArgumentFactories: true)
class ApiResponse<T> {
final bool success;
final String message;
final T? data;
const ApiResponse({
required this.success,
required this.message,
this.data,
});
// Factory constructor accepting an additional 'fromJsonT' parser function
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _$ApiResponseFromJson(json, fromJsonT);
Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$ApiResponseToJson(this, toJsonT);
}
How to Read Generic Responses in Your API Client: #
// A. Parsing Single Object data
final Map<String, dynamic> singleJsonMap = jsonDecode(rawResponse1);
final responseUser = ApiResponse<User>.fromJson(
singleJsonMap,
(json) => User.fromJson(json as Map<String, dynamic>),
);
print('Hello ${responseUser.data?.username}');
// B. Parsing Object List data
final Map<String, dynamic> listJsonMap = jsonDecode(rawResponse2);
final productListResponse = ApiResponse<List<Product>>.fromJson(
listJsonMap,
(json) => (json as List).map((item) => Product.fromJson(item as Map<String, dynamic>)).toList(),
);
print('Total products: ${productListResponse.data?.length}');
JsonConverter for Custom Data Type Conversions #
Sometimes you face situations where the data format sent by the backend server doesn’t match the data type you want to use in Dart. Some common data mismatch cases include:
- The server sends prices as Strings
"125000", while you want them asdoublein Flutter. - The server sends order status in integer format
1,2, or3, while you want to map those numbers into anOrderStatusenum class to be more meaningful in your UI code.
You can bridge these data type differences cleanly using the special JsonConverter<T, S> class (where T is the target data type in your Dart, and S is the source data type from the server’s JSON).
1. Creating a String to Double Converter #
import 'package:json_annotation/json_annotation.dart';
class StringToDoubleConverter implements JsonConverter<double, dynamic> {
const StringToDoubleConverter();
@override
double fromJson(dynamic json) {
if (json is num) return json.toDouble();
// If the data is a string, do safe manual parsing
return double.tryParse(json.toString()) ?? 0.0;
}
@override
dynamic toJson(double object) => object;
}
2. Creating an Integer to Enum Converter #
enum OrderStatus { pending, processing, shipped, completed }
class OrderStatusConverter implements JsonConverter<OrderStatus, int> {
const OrderStatusConverter();
@override
OrderStatus fromJson(int json) {
// Ensure the index is not out of enum range bounds
if (json >= 0 && json < OrderStatus.values.length) {
return OrderStatus.values[json];
}
return OrderStatus.pending;
}
@override
int toJson(OrderStatus object) => object.index;
}
3. Applying Converters to Your Model Classes #
You just insert your custom converter annotation right above the target property:
@freezed
class Transaction with _$Transaction {
const factory Transaction({
required String transactionId,
// Applying custom converters for automatic conversion during parsing
@StringToDoubleConverter() required double transferAmount,
@OrderStatusConverter() required OrderStatus status,
}) = _Transaction;
factory Transaction.fromJson(Map<String, dynamic> json) => _$TransactionFromJson(json);
}
Head-to-Head Comparison of the Three Approaches #
To make it easier to determine which approach is best to agree on in your team’s code standardization guide, here’s the comparison matrix:
| Feature / Parameter | Manual Parsing (dart:convert) | json_serializable | Freezed (Main Recommendation) |
|---|---|---|---|
| External Dependencies | None (Dart SDK built-in) | Requires generator setup | Requires full generator setup |
| Initial Setup Time | Instant / No preparation | Requires package installation | Requires full package installation |
| Typo Error Chance | Very High | Very Low | Very Low |
| copyWith Generation | Must be written manually | Not provided | Auto-generated |
| Object Comparison (==) | Must be written manually | Not provided | Auto-generated (value-based) |
| Union Types Support | None | None | Native support |
| Project Scalability | Very Poor | Good | Very Good |
As a practical conclusion, use Manual Parsing if you’re only writing very few data classes (e.g., 1-2 classes) in a small side project that doesn’t require long-term maintenance. Use Freezed as the mandatory standard for all production-scale commercial projects to guarantee data type safety, state stability, debugging ease, and maximum team productivity.
Summary #
- Deserialization is the process of parsing raw JSON text string data from API servers into type-safe Dart class object instances.
- Manual Parsing using
jsonDecodefrom thedart:convertlibrary is very typo-prone and doesn’t scale well for long-term projects.json_serializableremoves conversion boilerplate code by automatically generating.g.darthelper files through class annotation reading.- Freezed is a modern immutable class library generating
fromJson/toJsonfunctions,copyWith(),==overrides, andtoString()automatically.- Nested Objects require the
@Freezed(toJson: true)configuration or the globalexplicit_to_json: truesetting for smooth recursive conversion.- Freezed’s Union Types make organizing loading/success/failure status declaratively easier and force complete visual handling in the UI.
- Generic API Responses cut server response wrapper file duplication using the dynamic
genericArgumentFactoriesfeature.JsonConverterfacilitates adapting weird raw data types from the server (like string-formatted numbers or integer enum pointers) to the data types you need in Dart.- Watch Mode: Always use the
build_runner watchcommand during development so the generator runs automatically in the background when files are saved.