Networking & REST API Integration

Manual JSON Serialization with Model Classes

16 min Lesson 4 of 13

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 an int, a String, or null — the compiler cannot warn you.
  • String key typos: A misspelled key like data['emial'] silently returns null instead 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,
    };
  }
}
Note: The 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.

Tip: Always cast the result of 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 with json['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 int and sometimes double for 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.

Warning: Never add mutable setters to your model classes. Mutable models lead to shared-state bugs that are extremely difficult to trace — especially in asynchronous code. Keep every field 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.