Manual JSON Serialization with Model Classes
Manual JSON Serialization with Model Classes
When your Flutter app fetches data from a REST API, the response arrives as raw JSON — essentially a Map<String, dynamic> in Dart. Accessing values like data['user']['email'] throughout your codebase is fragile, hard to refactor, and offers no compile-time safety. Model classes with fromJson and toJson methods solve this by giving you typed, named objects that represent your API resources.
Why Typed Model Classes Matter
Raw map access has several serious drawbacks in production code:
- No type safety:
data['age']might return anint, aString, ornull— the compiler cannot warn you. - String key typos: A misspelled key like
data['emial']silently returnsnullinstead of throwing at compile time. - No IDE autocomplete: The IDE cannot suggest field names on a plain
Map. - Scattered parsing logic: JSON parsing is duplicated across widgets, repositories, and services.
A model class centralises all JSON parsing in one place, lets the type system verify your code, and makes refactoring trivial.
Anatomy of a Dart Model Class
A well-structured model class contains three things: final fields, a factory named constructor fromJson that parses a map, and a toJson method that serialises back to a map.
Basic Model: User
class User {
final int id;
final String name;
final String email;
final String? avatarUrl; // nullable — may be absent in JSON
const User({
required this.id,
required this.name,
required this.email,
this.avatarUrl,
});
/// Parses a [User] from a JSON map returned by the API.
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
avatarUrl: json['avatar_url'] as String?,
);
}
/// Converts this [User] back to a JSON map (useful for POST/PUT requests).
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
if (avatarUrl != null) 'avatar_url': avatarUrl,
};
}
}
factory keyword signals that the constructor may return a cached instance or delegate to a subclass — but for model parsing it is simply the conventional name for a named constructor that builds from external data. You can also use a regular named constructor; factory is idiomatic for JSON parsing.Handling Nested Objects and Lists
Real APIs rarely return flat JSON. You will frequently encounter nested objects and arrays. Each nested type gets its own model class, and fromJson delegates to those classes.
Nested Model: Post with Author and Comments
class Author {
final int id;
final String username;
const Author({required this.id, required this.username});
factory Author.fromJson(Map<String, dynamic> json) => Author(
id: json['id'] as int,
username: json['username'] as String,
);
Map<String, dynamic> toJson() => {'id': id, 'username': username};
}
class Post {
final int id;
final String title;
final String body;
final Author author;
final List<String> tags;
final DateTime publishedAt;
const Post({
required this.id,
required this.title,
required this.body,
required this.author,
required this.tags,
required this.publishedAt,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
// Delegate nested object parsing to Author.fromJson
author: Author.fromJson(json['author'] as Map<String, dynamic>),
// Parse a JSON array of strings into List<String>
tags: List<String>.from(json['tags'] as List),
// Parse ISO 8601 date string into DateTime
publishedAt: DateTime.parse(json['published_at'] as String),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'body': body,
'author': author.toJson(),
'tags': tags,
'published_at': publishedAt.toIso8601String(),
};
}
Parsing API Responses in Practice
After fetching JSON with http or dio, you decode the response body with jsonDecode, then pass the resulting map to your model's fromJson factory.
jsonDecode to the exact type you expect (Map<String, dynamic> for objects, List<dynamic> for arrays). A missing cast causes a runtime TypeError that is harder to diagnose than a clear assertion error from your model.Defensive Parsing: Handling Missing and Null Fields
APIs are not always well-behaved. Fields may be absent, unexpectedly null, or arrive as the wrong type. Use these strategies to write robust fromJson factories:
- Nullable fields: Declare the field as
String?and cast withjson['key'] as String?. - Default values: Use the null-coalescing operator:
(json['score'] as int?) ?? 0. - Missing list fields:
(json['tags'] as List?)?.map((e) => e as String).toList() ?? []. - Type coercion: If an API sometimes returns
intand sometimesdoublefor a numeric field, use(json['price'] as num).toDouble().
The copyWith Pattern
Model classes in Flutter are typically immutable. To create a modified version of an object (common in state management), add a copyWith method that returns a new instance with selected fields overridden.
final and use copyWith to produce updated copies.Summary
Manual JSON serialisation with fromJson / toJson gives your Flutter app a type-safe data layer without any code generation. You centralise all parsing logic in model classes, gain IDE autocomplete and compiler checking, and make your repository and UI code cleaner and more maintainable. As your project grows, you can graduate to code generation tools like json_serializable — but the manual pattern remains fundamental to understanding what those tools produce.