Dart Flutter JSON: dart:convert, json_serializable & Freezed
Last updated:
Dart Flutter JSON parsing uses dart:convert — jsonDecode(jsonStr) returns a dynamic (Map or List), and jsonEncode(obj) serializes a Dart object to a JSON string; for type-safe models, the json_serializable package generates fromJson() and toJson() methods at build time. Manual JSON parsing in Flutter requires casting dynamic to Map<String, dynamic> and accessing fields with map['key'] as String — missing null safety causes runtime errors; using json_serializable with @JsonSerializable(checked: true) adds type checking at parse time and throws CheckedFromJsonException with field-level error messages. This guide covers dart:convert jsonDecode/jsonEncode, manual fromJson/toJson patterns, json_serializable code generation, Freezed for immutable JSON models, handling nullable and default fields, @JsonKey for field name mapping, and making JSON HTTP API calls with the http package.
dart:convert: jsonDecode and jsonEncode Basics
jsonDecode in dart:convert parses a JSON string and returns a dynamic — the actual runtime type depends on the JSON: an object becomes Map<String, dynamic>, an array becomes List<dynamic>, a string becomes String, a number becomes int or double, a boolean becomes bool, and null becomes Null. You must cast the result to the expected type before using it. jsonEncode serializes Dart values — Map, List, String, num, bool, and null are supported natively; custom classes require a toJson() method or a toEncodable callback. Malformed JSON strings cause FormatException — always wrap in a try/catch.
import 'dart:convert';
// ── jsonDecode return types ─────────────────────────────────────
void main() {
// JSON object → Map<String, dynamic>
final String userJson = '{"id": 1, "name": "Alice", "active": true}';
final dynamic decoded = jsonDecode(userJson);
final Map<String, dynamic> userMap = decoded as Map<String, dynamic>;
print(userMap['name']); // "Alice"
print(userMap['id']); // 1 (int)
print(userMap['active']); // true (bool)
// JSON array → List<dynamic>
final String listJson = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]';
final List<dynamic> listDecoded = jsonDecode(listJson) as List<dynamic>;
print(listDecoded.length); // 2
// JSON primitives
print(jsonDecode('"hello"') as String); // "hello"
print(jsonDecode('42') as int); // 42
print(jsonDecode('3.14') as double); // 3.14
print(jsonDecode('true') as bool); // true
print(jsonDecode('null')); // null
// ── jsonEncode ────────────────────────────────────────────────
// Map → JSON string
final Map<String, dynamic> data = {
'id': 1,
'name': 'Alice',
'scores': [95, 87, 92],
'active': true,
'address': null,
};
print(jsonEncode(data));
// {"id":1,"name":"Alice","scores":[95,87,92],"active":true,"address":null}
// Custom class — requires toJson() method
final user = User(id: 1, name: 'Alice', email: 'alice@example.com');
print(jsonEncode(user.toJson()));
// Alternatively, use toEncodable callback for classes without toJson
print(jsonEncode(user, toEncodable: (obj) {
if (obj is User) return obj.toJson();
throw UnsupportedError('Cannot encode ${obj.runtimeType}');
}));
// ── FormatException for malformed JSON ────────────────────────
try {
jsonDecode('{invalid}');
} on FormatException catch (e) {
print('Malformed JSON: ${e.message}');
}
}
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
}A key dart:convert nuance: JSON numbers without a decimal point parse as int, and numbers with a decimal point parse as double — casting a double-valued JSON number to int throws a TypeError. Use (map['price'] as num).toDouble() or (map['count'] as num).toInt() to handle both representations safely. For deeply nested JSON, chain null-safe casts: (map['address'] as Map<String, dynamic>?)?.['city'] as String?.
Manual fromJson/toJson Pattern
The manual fromJson/toJson pattern gives full control over JSON deserialization without code generation. A factory constructor fromJson(Map<String, dynamic> json) reads each field from the map and constructs the object; a toJson() method returns a Map<String, dynamic> for serialization. This pattern works everywhere — no packages required — but becomes verbose for large models and must be kept in sync with model changes manually. Use it for small, stable models or when code generation is not available.
import 'dart:convert';
// ── Simple model — manual fromJson/toJson ──────────────────────
class User {
final int id;
final String name;
final String? email; // nullable — may be absent in JSON
final List<String> tags;
final Address address;
User({
required this.id,
required this.name,
this.email,
required this.tags,
required this.address,
});
// Factory constructor — reads from Map<String, dynamic>
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String?, // null-safe cast
tags: (json['tags'] as List<dynamic>?) // default to empty list
?.map((e) => e as String)
.toList() ?? [],
address: Address.fromJson( // nested object
json['address'] as Map<String, dynamic>,
),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (email != null) 'email': email, // omit null from output
'tags': tags,
'address': address.toJson(), // serialize nested object
};
}
// ── Nested object ──────────────────────────────────────────────
class Address {
final String street;
final String city;
final String? zip;
Address({required this.street, required this.city, this.zip});
factory Address.fromJson(Map<String, dynamic> json) => Address(
street: json['street'] as String,
city: json['city'] as String,
zip: json['zip'] as String?,
);
Map<String, dynamic> toJson() => {
'street': street,
'city': city,
if (zip != null) 'zip': zip,
};
}
// ── Usage ──────────────────────────────────────────────────────
void main() {
const String jsonStr = '''
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"tags": ["admin", "editor"],
"address": { "street": "123 Main St", "city": "Springfield", "zip": "12345" }
}
''';
final user = User.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
print(user.name); // Alice
print(user.address.city); // Springfield
print(jsonEncode(user.toJson()));
// ── List of objects ───────────────────────────────────────────
const String listJson = '[{"id":1,"name":"Alice","tags":[],"address":{"street":"A","city":"B"}},{"id":2,"name":"Bob","tags":[],"address":{"street":"C","city":"D"}}]';
final List<User> users = (jsonDecode(listJson) as List<dynamic>)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
print(users.length); // 2
// ── Default values for missing keys ───────────────────────────
// Use ?? operator inline
final role = json['role'] as String? ?? 'viewer';
// Use map.containsKey() to distinguish absent vs null
final hasEmail = (jsonDecode(jsonStr) as Map<String, dynamic>).containsKey('email');
}A subtle Dart null safety rule: json['key'] as String throws a TypeError if the field is null or missing (both return null from map lookup). Use json['key'] as String? for any field that might be absent or null, then handle the nullable result separately. For required fields you trust the API to provide, the non-nullable cast is fine — it will surface bugs early as a runtime error rather than silently passing null through the system.
json_serializable: Code Generation Setup
json_serializable eliminates manual fromJson/toJson boilerplate by generating them at build time. The generator reads your annotated class definition and produces a .g.dart file with all the serialization logic. Configuration options via @JsonSerializable and @JsonKey annotations control field name mapping, null handling, type checking, and nested object serialization.
# pubspec.yaml
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0
# Run code generation:
# dart run build_runner build
# dart run build_runner build --delete-conflicting-outputs
# dart run build_runner watch (auto-regenerate on file changes)
# ── Model file: user.dart ─────────────────────────────────────
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart'; // REQUIRED — points to the generated file
@JsonSerializable(
checked: true, // throws CheckedFromJsonException with field info
explicitToJson: true, // serialize nested objects (calls their toJson())
fieldRename: FieldRename.snake, // userId → user_id in JSON automatically
)
class User {
final int id;
final String name;
@JsonKey(name: 'email_address') // override field rename for this field only
final String? emailAddress;
@JsonKey(defaultValue: 0) // use 0 if 'score' key is missing from JSON
final int score;
@JsonKey(includeIfNull: false) // omit from toJson() output when null
final String? avatarUrl;
@JsonKey(ignore: true) // exclude from both fromJson and toJson
String? transientField;
final List<Tag> tags; // nested list — explicitToJson handles this
final Address address; // nested object
User({
required this.id,
required this.name,
this.emailAddress,
required this.score,
this.avatarUrl,
this.transientField,
required this.tags,
required this.address,
});
// Delegate to generated code
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
@JsonSerializable()
class Address {
final String street;
final String city;
Address({required this.street, required this.city});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}
@JsonSerializable()
class Tag {
final String name;
final String color;
Tag({required this.name, required this.color});
factory Tag.fromJson(Map<String, dynamic> json) => _$TagFromJson(json);
Map<String, dynamic> toJson() => _$TagToJson(this);
}
// ── Usage ──────────────────────────────────────────────────────
import 'dart:convert';
final user = User.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
final json = user.toJson();
final jsonString = jsonEncode(user.toJson());The checked: true option is highly recommended for production apps — it wraps field casts in type-checking logic and throws CheckedFromJsonException when a field has an unexpected type, including the field name and received value in the error message. Without checked: true, a type mismatch silently returns null or throws an opaque TypeError. Always run build_runner build after adding new fields — the generated .g.dart file must be committed alongside the model file so that CI builds without running build_runner pass.
Freezed: Immutable JSON Models
Freezed extends json_serializable with immutable data classes, copyWith() for non-destructive updates, value equality, and union types (discriminated unions). It is the most complete JSON model solution for Flutter — a Freezed class is immutable by default, meaning all fields are final and cannot be changed; copyWith() creates a new instance with selected fields changed. Union types model API responses with multiple shapes (loading / data / error) in a single type with exhaustive pattern matching.
# pubspec.yaml
dependencies:
freezed_annotation: ^2.4.0
json_annotation: ^4.9.0
dev_dependencies:
freezed: ^2.5.0
json_serializable: ^6.8.0
build_runner: ^2.4.0
# ── Simple Freezed JSON model: user.dart ─────────────────────
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart'; // Freezed generates this
part 'user.g.dart'; // json_serializable generates this
@freezed
class User with _$User {
const factory User({
required int id,
required String name,
String? email,
@Default([]) List<String> tags, // @Default replaces @JsonKey(defaultValue:)
@JsonKey(name: 'created_at') required DateTime createdAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// Usage
final user = User(id: 1, name: 'Alice', email: 'alice@example.com', createdAt: DateTime.now());
final updated = user.copyWith(name: 'Bob'); // immutable update — returns new instance
print(user == updated); // false (value equality)
print(user.copyWith() == user); // true (all fields same)
// ── Union type — API response states ──────────────────────────
@freezed
sealed class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse.loading() = _Loading;
const factory ApiResponse.data(T value) = _Data;
const factory ApiResponse.error(String message, {int? statusCode}) = _Error;
}
// Usage — exhaustive pattern matching with when()
final ApiResponse<User> response = ApiResponse.data(user);
final widget = response.when(
loading: () => CircularProgressIndicator(),
data: (user) => Text(user.name),
error: (msg, statusCode) => Text('Error $statusCode: $msg'),
);
// map() is like when() but returns the union type — useful for transformations
final mapped = response.map(
loading: (l) => ApiResponse.loading(),
data: (d) => ApiResponse.data(d.value.copyWith(name: d.value.name.toUpperCase())),
error: (e) => ApiResponse.error(e.message),
);
// ── Freezed + JSON: union types with discriminator ────────────
// For JSON discriminated unions ({"type": "circle"} vs {"type": "rectangle"})
@Freezed(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
sealed class Shape with _$Shape {
const factory Shape.circle({required double radius}) = Circle;
const factory Shape.rectangle({required double width, required double height}) = Rectangle;
factory Shape.fromJson(Map<String, dynamic> json) => _$ShapeFromJson(json);
}
// JSON: {"type": "circle", "radius": 5.0} → Shape.circle(radius: 5.0)
// JSON: {"type": "rectangle", "width": 3.0, "height": 4.0} → Shape.rectangle(...)Freezed requires running dart run build_runner build after any model change — it generates both a .freezed.dart file (the immutable class implementation) and a .g.dart file (the JSON serialization code). Both generated files must be committed to source control. The @Default(value) annotation in Freezed replaces @JsonKey(defaultValue: value) from json_serializable — it sets both the constructor default and the JSON deserialization default in one annotation.
Nullable Fields and Default Values
Dart null safety makes JSON field nullability explicit at the type level — String? means the field may be absent or null in JSON, while String means it must be present and non-null. Handling nullable JSON fields correctly prevents the most common Flutter JSON runtime errors. The @JsonKey annotation provides fine-grained control over how missing and null fields are treated during both deserialization and serialization.
import 'package:json_annotation/json_annotation.dart';
part 'profile.g.dart';
@JsonSerializable(checked: true)
class Profile {
// Required non-null — throws CheckedFromJsonException if missing or null
final String userId;
// Nullable — field may be absent or explicitly null in JSON
final String? displayName;
// Default value — use '' if 'bio' key is missing (NOT if it's null)
@JsonKey(defaultValue: '')
final String bio;
// Default list — use [] if 'interests' key is absent
@JsonKey(defaultValue: <String>[])
final List<String> interests;
// includeIfNull: false — omit this field from toJson() output when null
// API receives a cleaner payload without "avatarUrl": null
@JsonKey(includeIfNull: false)
final String? avatarUrl;
// includeFromJson: false — never read from JSON (always use constructor default)
@JsonKey(includeFromJson: false, defaultValue: false)
final bool isSelected;
// includeToJson: false — never write to JSON (client-only field)
@JsonKey(includeToJson: false)
final DateTime? lastViewedAt;
// readValue — custom transform on the raw JSON value before assignment
@JsonKey(readValue: _parseScore)
final int score;
static Object? _parseScore(Map map, String key) {
// Accept either int or string representation from inconsistent API
final raw = map[key];
if (raw is String) return int.tryParse(raw) ?? 0;
return raw;
}
Profile({
required this.userId,
this.displayName,
required this.bio,
required this.interests,
this.avatarUrl,
this.isSelected = false,
this.lastViewedAt,
required this.score,
});
factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
Map<String, dynamic> toJson() => _$ProfileToJson(this);
}
// ── Manual null handling patterns ─────────────────────────────
Map<String, dynamic> json = jsonDecode(apiResponseStr) as Map<String, dynamic>;
// Pattern 1: null-coalescing default
final name = json['name'] as String? ?? 'Anonymous';
// Pattern 2: null-safe chained access for nested nullable objects
final city = (json['address'] as Map<String, dynamic>?)?['city'] as String?;
// Pattern 3: distinguish absent key vs explicit null
final hasPrefs = json.containsKey('preferences');
final prefsValue = json['preferences']; // null if absent OR explicitly null
// Pattern 4: safe num-to-int/double conversion
final price = (json['price'] as num?)?.toDouble() ?? 0.0;
final count = (json['count'] as num?)?.toInt() ?? 0;A critical distinction: @JsonKey(defaultValue: '') only applies when the key is absent from the JSON map — if the key is present with a null value, the generated code still assigns null (causing a TypeError for non-nullable fields). To handle both absent keys and explicit null values, use a nullable field type (String?) and apply the default in the constructor or via the ?? operator in a custom fromJson.
http Package JSON API Integration
Flutter's http package is the standard choice for simple JSON API calls — lightweight, well-documented, and sufficient for most apps. All responses come back as a Response object with a body String and a statusCode int. You must decode the body string with jsonDecode and check the status code before parsing. For production apps, wrap http calls in a service class to centralize base URL, auth headers, and error handling.
import 'dart:convert';
import 'package:http/http.dart' as http;
// ── GET request — parse JSON response ─────────────────────────
Future<User> fetchUser(int id) async {
final uri = Uri.parse('https://api.example.com/users/$id');
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
);
if (response.statusCode == 200) {
final map = jsonDecode(response.body) as Map<String, dynamic>;
return User.fromJson(map);
} else if (response.statusCode == 404) {
throw NotFoundException('User $id not found');
} else {
throw ApiException('GET /users/$id failed: ${response.statusCode}');
}
}
// ── POST request — send JSON body ─────────────────────────────
Future<User> createUser({required String name, required String email}) async {
final uri = Uri.parse('https://api.example.com/users');
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/json', // REQUIRED for JSON body
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({'name': name, 'email': email}),
);
if (response.statusCode == 201) {
return User.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
// Parse error body if server returns structured errors
final errorMap = jsonDecode(response.body) as Map<String, dynamic>;
throw ApiException(errorMap['message'] as String? ?? 'Unknown error');
}
}
// ── Fetch list of JSON objects ─────────────────────────────────
Future<List<User>> fetchUsers() async {
final response = await http.get(Uri.parse('https://api.example.com/users'));
if (response.statusCode != 200) throw ApiException('Failed to load users');
final list = jsonDecode(response.body) as List<dynamic>;
return list
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
}
// ── Typed API client class ─────────────────────────────────────
class ApiClient {
final String baseUrl;
final String? accessToken;
final http.Client _client;
ApiClient({required this.baseUrl, this.accessToken})
: _client = http.Client();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
};
Future<T> get<T>(String path, T Function(dynamic json) fromJson) async {
final response = await _client.get(
Uri.parse('$baseUrl$path'),
headers: _headers,
);
_checkStatus(response);
return fromJson(jsonDecode(response.body));
}
Future<T> post<T>(
String path,
Map<String, dynamic> body,
T Function(dynamic json) fromJson,
) async {
final response = await _client.post(
Uri.parse('$baseUrl$path'),
headers: _headers,
body: jsonEncode(body),
);
_checkStatus(response);
return fromJson(jsonDecode(response.body));
}
void _checkStatus(http.Response response) {
if (response.statusCode >= 400) {
throw ApiException('${response.statusCode}: ${response.body}');
}
}
void dispose() => _client.close();
}
// Usage
final api = ApiClient(baseUrl: 'https://api.example.com', accessToken: token);
final user = await api.get('/users/1', (json) => User.fromJson(json as Map<String, dynamic>));Always close the http.Client instance (call client.close()) when the app disposes of a screen or service — keeping clients open does not cause memory leaks in simple apps, but for long-running services it prevents the underlying connection pool from being freed. In Flutter widgets, dispose the client in the dispose() method of a State class, or manage the client lifecycle with a Provider or Riverpod scoped to the widget tree.
Dio HTTP Client and Interceptors for JSON
Dio is a third-party HTTP client for Flutter with built-in interceptors, automatic JSON parsing, request cancellation, and form data support — features the http package lacks. Dio automatically deserializes JSON responses into Map/List when the server responds with Content-Type: application/json, so you skip the manual jsonDecode step. Interceptors attach auth tokens, log requests, and handle token refresh globally — a single interceptor replaces repetitive auth header code in every API method.
import 'package:dio/dio.dart';
// ── Dio setup with BaseOptions ─────────────────────────────────
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
responseType: ResponseType.json, // auto-parse JSON (default)
));
// ── Auth interceptor — inject token into every request ─────────
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final token = getAccessToken(); // read from secure storage
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (DioException error, handler) async {
// Token refresh on 401
if (error.response?.statusCode == 401) {
try {
final newToken = await refreshToken();
error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
// Retry the original request with new token
final retryResponse = await dio.fetch(error.requestOptions);
return handler.resolve(retryResponse);
} catch (e) {
// Refresh failed — force logout
navigateToLogin();
}
}
handler.next(error);
},
));
// ── GET — response.data is already parsed (no jsonDecode needed) ──
Future<User> fetchUser(int id) async {
final response = await dio.get('/users/$id');
// response.data is Map<String, dynamic> automatically
return User.fromJson(response.data as Map<String, dynamic>);
}
// ── POST with JSON body ────────────────────────────────────────
Future<User> createUser(Map<String, dynamic> body) async {
final response = await dio.post('/users', data: body); // data auto-encodes to JSON
return User.fromJson(response.data as Map<String, dynamic>);
}
// ── Request cancellation with CancelToken ─────────────────────
CancelToken? _searchToken;
Future<List<User>> searchUsers(String query) async {
_searchToken?.cancel('New search started');
_searchToken = CancelToken();
try {
final response = await dio.get(
'/users/search',
queryParameters: {'q': query},
cancelToken: _searchToken,
);
return (response.data as List<dynamic>)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
if (CancelToken.isCancel(e)) return []; // request was cancelled — not an error
rethrow;
}
}
// ── FormData vs JSON body ──────────────────────────────────────
// JSON body (default for Map data):
await dio.post('/users', data: {'name': 'Alice', 'email': 'alice@example.com'});
// FormData (for file uploads or multipart):
final formData = FormData.fromMap({
'name': 'Alice',
'avatar': await MultipartFile.fromFile('/path/to/photo.jpg', filename: 'avatar.jpg'),
});
await dio.post('/users', data: formData);
// ── Upload progress tracking ───────────────────────────────────
await dio.post(
'/upload',
data: formData,
onSendProgress: (sent, total) {
print('Upload: ${(sent / total * 100).toStringAsFixed(0)}%');
},
);Choose http for simple scripts, CLI tools, and apps with straightforward GET/POST calls where interceptors are not needed. Choose Dio when your app requires auth token injection and refresh, request cancellation (search-as-you-type patterns), upload/download progress, retry logic, or a base URL configuration shared across all requests. Dio's DioException wraps all HTTP errors — check error.response?.statusCode and error.type (connect timeout, receive timeout, cancel, bad response) for structured error handling.
Key Terms
- jsonDecode
- A function in Dart's
dart:convertlibrary that parses a JSON string into a Dartdynamicvalue. The returned runtime type mirrors the JSON structure: a JSON object becomesMap<String, dynamic>, a JSON array becomesList<dynamic>, and JSON primitives become their Dart equivalents (String,int,double,bool, ornull). Because the return type isdynamic, you must cast the result to the expected Dart type before accessing fields. Malformed JSON input causes aFormatExceptionat runtime; always wrap in atry/catchblock for user-supplied or network-sourced JSON. The companion functionjsonEncodeserializes Dart values to a JSON string — it natively supportsMap,List,String,num,bool, andnull, and callstoJson()on custom objects. - fromJson factory
- A Dart factory constructor that takes a
Map<String, dynamic>(the result ofjsonDecode) and returns an instance of the model class. The convention isfactory ModelClass.fromJson(Map<String, dynamic> json). In the manual pattern, the factory body reads each field from the map with explicit casts (json['name'] as String). Withjson_serializable, the factory body delegates to the generated function:return _$ModelClassFromJson(json);. The factory pattern is preferred over a regular constructor because it allows returning an existing instance or performing validation before construction — though in practice most Flutter JSON models use it purely for the naming convention. Always ensure theMap<String, dynamic>cast is applied to thejsonDecoderesult before passing it tofromJson, not inside the factory. - json_serializable
- A Dart code generation package that reads
@JsonSerializable-annotated class definitions and generatesfromJson/toJsonimplementations in a.g.dartfile at build time. Controlled by annotations:@JsonSerializable(checked: true)adds type verification with detailed error messages;@JsonSerializable(explicitToJson: true)callstoJson()on nested objects;@JsonSerializable(fieldRename: FieldRename.snake)auto-converts camelCase Dart field names to snake_case JSON keys. Per-field overrides use@JsonKey:namefor key mapping,defaultValuefor missing keys,includeIfNullfor output control, andignoreto exclude fields entirely. Requiresbuild_runnerto generate the code; the generated file must be re-run after any model change. - Freezed
- A Dart code generation package that creates immutable data classes with value equality,
copyWith(),toString(), and optional union types. When combined withjson_serializable(via afromJsonfactory and the@freezedannotation), Freezed generates both the immutable class boilerplate (.freezed.dart) and the JSON serialization code (.g.dart). Freezed'scopyWith()method returns a new instance with selected fields replaced, preserving immutability while enabling convenient model updates. Union types — multipleconst factoryconstructors on a single@freezedclass — enable exhaustive pattern matching viawhen()andmap(), making Freezed the standard choice for modeling API states (loading / data / error) in Flutter apps using Riverpod or BLoC. - @JsonKey
- A field-level annotation from
package:json_annotationthat overridesjson_serializable's default behavior for a specific field. Key parameters:namemaps the Dart field to a different JSON key name (bidirectional — applies to bothfromJsonandtoJson);defaultValueprovides a fallback value when the JSON key is absent during deserialization;includeIfNullcontrols whether null values are written to the serialized output (falseomits null fields fromtoJson);includeFromJsonandincludeToJsonselectively exclude a field from deserialization or serialization;ignoreexcludes the field from both directions;readValueprovides a custom function to transform the raw JSON value before assignment;toJsonandfromJsonaccept custom converter functions for non-standard types. - build_runner
- A Dart build tool that executes code generators — including
json_serializableandfreezed— to produce.g.dartand.freezed.dartfiles from annotated source code. Rundart run build_runner buildfor a one-time generation pass, ordart run build_runner watchto automatically regenerate files whenever source files change during development. Add--delete-conflicting-outputswhen generated files conflict with existing outputs (common after package upgrades or merging branches). Generated files (*.g.dart,*.freezed.dart) should be committed to source control so that CI/CD pipelines can build without runningbuild_runner. In large projects,build_runnercan be slow — usedart run build_runner build --build-filter=lib/src/models/to limit generation to specific directories.
FAQ
How do I parse JSON in Flutter with dart:convert?
Import dart:convert and call jsonDecode(jsonString) to parse a JSON string into a Dart dynamic value. For a JSON object, the result is a Map<String, dynamic>; for a JSON array, it is a List<dynamic>. Cast the result: final map = jsonDecode(str) as Map<String, dynamic>. Access fields with typed casts: map['name'] as String, map['count'] as int, using the null-safe operator (as String?) for optional fields. To serialize, call jsonEncode(object) — it accepts Map, List, String, num, bool, and null natively. For custom classes, implement a toJson() method returning Map<String, dynamic> and pass instance.toJson() to jsonEncode. Wrap jsonDecode calls in a try/catch FormatException block to handle malformed JSON strings from network responses or user input.
What is json_serializable and how does it help with Flutter JSON?
json_serializable is a Dart code generation package that automatically creates fromJson() factory constructors and toJson() methods for your model classes, eliminating hand-written boilerplate. Add json_annotation to dependencies and json_serializable plus build_runner to dev_dependencies in pubspec.yaml. Annotate your class with @JsonSerializable(), add a part 'model.g.dart'; directive, define your fields with null-safe types, and add the delegate factory and method. Run dart run build_runner build to generate the implementation. After any model field change, re-run build_runner. Use @JsonSerializable(checked: true) for type-safe parsing with field-level error messages, @JsonSerializable(explicitToJson: true) for nested objects, and @JsonKey annotations for field-level customization. Commit the generated .g.dart files to source control so CI builds without running build_runner.
How do I handle nullable JSON fields in Dart?
Declare the Dart field as a nullable type (String?) for any JSON field that may be absent or null. In manual fromJson(), use json['field'] as String? for nullable fields — this returns null safely whether the key is missing or the value is null. Use the ?? operator for inline defaults: json['name'] as String? ?? 'Anonymous'. With json_serializable, declare the field as String? and the generated code handles nullability; use @JsonKey(defaultValue: '') to substitute a default when the key is absent (note: does not help when the key is present but null). Use @JsonKey(includeIfNull: false) to omit null fields from the serialized toJson() output. For numeric fields from inconsistent APIs that send either a number or a string, use (json['count'] as num?)?.toInt() ?? 0 — casting to num first handles both int and double JSON representations.
What is Freezed and how does it work with JSON?
Freezed is a Dart code generation package that generates immutable data classes with copyWith(), value equality (== and hashCode), toString(), and union types. When combined with json_serializable, Freezed models are both immutable and JSON-serializable. Annotate a class with @freezed, define a const factory constructor with the fields, add a fromJson factory delegating to the generated code, and run dart run build_runner build. Freezed generates copyWith() for non-destructive updates — user.copyWith(name: 'Bob') returns a new User with all other fields unchanged. Union types model discriminated states — define multiple const factory constructors and use when() for exhaustive pattern matching. This makes Freezed the standard choice for API response state modeling (loading, data, error) in Flutter apps with Riverpod or BLoC architectures.
How do I map JSON field names to Dart property names?
Use @JsonKey(name: 'json_key_name') on a Dart field to map it to a different JSON key. The most common case is snake_case JSON keys mapping to camelCase Dart fields: @JsonKey(name: 'user_id') final String userId. The name mapping is bidirectional — it applies to both fromJson deserialization and toJson serialization. For project-wide snake_case mapping, use @JsonSerializable(fieldRename: FieldRename.snake) on the class — this converts all camelCase Dart fields to snake_case JSON keys automatically without requiring @JsonKey on each field. Individual @JsonKey(name:) annotations override the class-level fieldRename setting. For JSON keys that are Dart reserved words (like class or for) or start with special characters (like @type), always use @JsonKey(name:) to map to a valid Dart identifier.
How do I make a JSON API request in Flutter?
Add http to pubspec.yaml dependencies and import package:http/http.dart as http. For a GET request: final response = await http.get(Uri.parse(url), headers: {'Authorization': 'Bearer \$token'}); then check response.statusCode == 200 before parsing: final map = jsonDecode(response.body) as Map<String, dynamic>; and final user = User.fromJson(map);. For a POST with a JSON body: set 'Content-Type': 'application/json' in headers and pass body: jsonEncode({'key': value});. Always handle non-200 status codes and catch SocketException for network connectivity failures. For apps with multiple API endpoints, create a typed service class that wraps http calls, centralizes the base URL, auth headers, and error handling, and exposes typed methods returning model objects rather than raw responses.
How do I parse a list of JSON objects in Dart?
When the API response is a JSON array, jsonDecode returns a List<dynamic>. Cast and map each element: final list = jsonDecode(jsonStr) as List<dynamic>; then final users = list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();. The explicit cast to Map<String, dynamic> on each element is required because the list contains dynamic values. For nested arrays within an object response ({"users": [...]}), first decode the outer map: final map = jsonDecode(str) as Map<String, dynamic>; then final users = (map['users'] as List<dynamic>).map((e) => User.fromJson(e as Map<String, dynamic>)).toList();. With json_serializable, declare List<User> fields in your model class and the generated code handles list mapping automatically — no manual iteration needed for nested arrays in a parent model.
What is the difference between Dio and http package for JSON API calls?
The http package is Flutter's official lightweight HTTP client — simple for basic GET/POST, returns a Response with a body string requiring manual jsonDecode. It has no built-in interceptors, cancellation, retry, or response transformation. Dio is a third-party client adding: Interceptors for globally injecting auth tokens and handling 401 token refresh; automatic JSON parsing (when server returns application/json, response.data is already a Map/List — no jsonDecode needed); CancelToken for request cancellation (search-as-you-type); BaseOptions for a shared base URL and default headers; and upload/download progress callbacks (onSendProgress, onReceiveProgress). Use http for simple apps, scripts, and Flutter packages. Use Dio for production Flutter apps that need auth token refresh interceptors, cancellation, retry logic, or centralized request configuration. Both support null safety and are actively maintained.
Further reading and primary sources
- dart:convert library — Flutter Documentation — Official dart:convert API reference for jsonDecode, jsonEncode, and JSON codec
- json_serializable package — pub.dev — json_serializable code generation setup, @JsonSerializable options, and @JsonKey reference
- Freezed package — pub.dev — Freezed documentation for immutable models, union types, copyWith, and JSON integration
- JSON and serialization — Flutter Documentation — Flutter official guide covering manual JSON, json_serializable, and code generation
- Dio package — pub.dev — Dio HTTP client documentation: interceptors, BaseOptions, CancelToken, and FormData