Flutter Parse JSON: Model Classes, fromJson, and toJson

Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter parse JSON guide, with a JSON payload mapping into a typed Dart model class via fromJson.
Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter parse JSON guide, with a JSON payload mapping into a typed Dart model class via fromJson.

JSON arrives as a string and you want it as a typed object. The bridge in Flutter is two short pieces: jsonDecode from dart:convert, which turns the string into a Map<String, dynamic>, and a model class with a fromJson factory that reads that map into named, typed fields. Add a toJson method for the return trip. This guide covers the map shape, hand-writing fromJson and toJson, nested objects and lists, null safety on fields, and when to switch to generated code, all with paste-ready snippets.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Every snippet below is paste-ready against current stable Flutter. The JSON usually comes from a network call, so if you have not wired one yet, the HTTP requests guide shows where response.body comes from.

Follow me on Instagram@sagnikteaches

We'll decode the string, build a model with fromJson, add toJson, handle nesting and lists, lock down null safety, and weigh code generation.

Connect on LinkedInSagnik Bhattacharya

If you'd rather watch models and serialization built into a real screen, the channel covers the full data flow from API to UI.

Subscribe on YouTube@codingliquids

jsonDecode and the map shape

jsonDecode lives in the built-in dart:convert library — no package to add. It takes a JSON string and returns a Dart value whose static type is dynamic: a Map<String, dynamic> for a JSON object, or a List<dynamic> for an array.

import 'dart:convert';

const body = '{"id": 1, "name": "Ada", "active": true}';
final Map<String, dynamic> data = jsonDecode(body);
print(data['name']);   // Ada

The keys are strings; the values are dynamic, so a number could be int or double and a missing key reads as null. Reading raw maps like this is fine for one field, but it scatters string keys through your code. A model class fixes that.

A model with fromJson

Wrap the map in a class. The convention is a fromJson factory that takes the decoded map and returns a typed instance, casting each value to the expected type.

class User {
  final int id;
  final String name;
  final bool active;
  const User({required this.id, required this.name, required this.active});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        active: json['active'] as bool,
      );
}

final user = User.fromJson(jsonDecode(body));

Now the rest of your app uses user.name, not data['name'] — autocomplete works, typos are caught at compile time, and the JSON shape lives in one place.

toJson for sending

To send the object back — a POST or PUT — add a toJson method that produces a Map<String, dynamic>, then hand it to jsonEncode.

Map<String, dynamic> toJson() => {
      'id': id,
      'name': name,
      'active': active,
    };

final payload = jsonEncode(user.toJson());   // back to a JSON string

So fromJson reads inbound data and toJson writes outbound data. Keep the key names identical on both sides and the round trip stays lossless.

The Complete Flutter Guide course thumbnail

Model real API responses

The Complete Flutter Guide covers models, serialization, and the full API-to-UI flow inside real, shipped apps.

Enrol now

Nested objects and lists

Real payloads nest. A user with an address object and a list of roles maps each nested piece through its own fromJson, and each list element via map.

class Address {
  final String city;
  const Address({required this.city});
  factory Address.fromJson(Map<String, dynamic> json) =>
      Address(city: json['city'] as String);
}

class Member {
  final String name;
  final Address address;          // nested object
  final List<String> roles;       // list of primitives

  const Member({required this.name, required this.address, required this.roles});

  factory Member.fromJson(Map<String, dynamic> json) => Member(
        name: json['name'] as String,
        address: Address.fromJson(json['address'] as Map<String, dynamic>),
        roles: (json['roles'] as List).map((e) => e as String).toList(),
      );
}

For a top-level array of objects, decode then map each element through the model:

final List<User> users = (jsonDecode(body) as List)
    .map((e) => User.fromJson(e as Map<String, dynamic>))
    .toList();

Null safety on fields

APIs omit fields and send nulls. Make optional fields nullable and supply defaults so a missing key never crashes the parse. The null-aware and null-coalescing operators carry the load.

factory Profile.fromJson(Map<String, dynamic> json) => Profile(
      name: json['name'] as String? ?? 'Anonymous',   // missing → default
      age: json['age'] as int?,                        // genuinely optional
      tags: (json['tags'] as List?)?.cast<String>() ?? const [],
    );

Cast with the nullable type (as String?) when a field may be absent, and fall back with ??. Watch the number case too: JSON 1 may decode as int, so for a double field use (json['price'] as num).toDouble() to accept both.

Parse nested objects

The section above showed a nested Address with a single field; real nested objects carry several, and the pattern scales the same way. The rule is simple: a parent never reaches into a child's keys. It casts the child's slice of the map to Map<String, dynamic> and hands it straight to the child's own fromJson. Each class owns exactly the keys it declares.

class Address {
  final String street;
  final String city;
  final String postcode;

  const Address({
    required this.street,
    required this.city,
    required this.postcode,
  });

  factory Address.fromJson(Map<String, dynamic> json) => Address(
        street: json['street'] as String,
        city: json['city'] as String,
        postcode: json['postcode'] as String,
      );

  Map<String, dynamic> toJson() => {
        'street': street,
        'city': city,
        'postcode': postcode,
      };
}

class User {
  final int id;
  final String name;
  final Address address;          // nested object

  const User({required this.id, required this.name, required this.address});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        address: Address.fromJson(json['address'] as Map<String, dynamic>),
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'address': address.toJson(),   // delegate back down
      };
}

The as Map<String, dynamic> cast on json['address'] is what makes this safe: jsonDecode returns the nested object as dynamic, and the cast turns it into the exact shape Address.fromJson expects. On the way out, the parent's toJson calls address.toJson() so the nesting is rebuilt symmetrically. Nest three or four levels deep and nothing changes — each layer only knows its own children.

Parse a list field

A list of objects is the case that trips people up, because each element is itself a map that needs its own fromJson. Cast the field to List, map every element through the child factory, and collect with toList().

class Tag {
  final String label;
  final String colour;

  const Tag({required this.label, required this.colour});

  factory Tag.fromJson(Map<String, dynamic> json) => Tag(
        label: json['label'] as String,
        colour: json['colour'] as String,
      );

  Map<String, dynamic> toJson() => {'label': label, 'colour': colour};
}

class User {
  final int id;
  final List<Tag> tags;           // array of objects

  const User({required this.id, required this.tags});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        tags: (json['tags'] as List)
            .map((e) => Tag.fromJson(e as Map<String, dynamic>))
            .toList(),
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'tags': tags.map((t) => t.toJson()).toList(),
      };
}

Two casts do the work. json['tags'] as List turns the dynamic value into something you can map over, and e as Map<String, dynamic> inside the callback gives each element the shape Tag.fromJson needs. Going outbound, tags.map((t) => t.toJson()).toList() mirrors the read exactly.

Dates, enums, and numbers

JSON has no date, enum, or strict number types, so three field kinds need a small conversion as they cross the boundary. Get these right once and they stop being a source of runtime surprises.

Dates. A timestamp arrives as an ISO-8601 string; parse it with DateTime.parse and write it back with toIso8601String.

enum Status { active, suspended, unknown }

Status statusFromName(String? raw) => Status.values.firstWhere(
      (s) => s.name == raw,
      orElse: () => Status.unknown,   // safe fallback for new/unknown values
    );

class Account {
  final DateTime createdAt;
  final Status status;
  final double price;

  const Account({
    required this.createdAt,
    required this.status,
    required this.price,
  });

  factory Account.fromJson(Map<String, dynamic> json) => Account(
        createdAt: DateTime.parse(json['createdAt'] as String),
        status: statusFromName(json['status'] as String?),
        price: (json['price'] as num).toDouble(),   // accepts 1 or 1.0
      );

  Map<String, dynamic> toJson() => {
        'createdAt': createdAt.toIso8601String(),
        'status': status.name,
        'price': price,
      };
}

Enums. Parse by name with firstWhere and always pass an orElse. An API that adds a new status value should not crash an old build, so an unknown fallback keeps the parse alive. Writing back, status.name gives the string.

Numbers. This is the quiet trap. JSON makes no distinction between 1 and 1.0, so a value the API treats as a price can decode as an int. Casting json['price'] as double then throws on a plain 1. Cast to num first and call .toDouble()num is the common supertype of int and double, so it accepts either and normalises to the type you want.

Parse defensively: nulls and missing keys

A strict cast trusts the payload completely. json['name'] as String reads a missing key as null, and casting null to the non-nullable String throws a runtime TypeError — the parse dies on the first absent field. Production APIs omit keys, send null, and add fields between releases, so cast through a nullable type and supply a default.

factory User.fromJson(Map<String, dynamic> json) => User(
      // missing or null → fall back, never throw
      name: json['name'] as String? ?? '',
      age: json['age'] as int?,                         // genuinely optional
      active: json['active'] as bool? ?? false,
      score: (json['score'] as num?)?.toDouble() ?? 0,  // nullable + number trap
      tags: (json['tags'] as List?)
              ?.map((e) => e as String)
              .toList() ??
          const <String>[],
    );

The shape is always the same: cast with the nullable type (as String?, as List?), then decide what a missing value means. Use ?? '' or ?? false when the field is required by your UI but the server might drop it, and keep the field genuinely nullable (int? age) when "absent" is a real state your code handles. The number trap reappears here — (json['score'] as num?)?.toDouble() stays null-safe and accepts both int and double. A model that never throws on a slightly-off payload is the difference between a blank list and a crashed screen.

Generate it with json_serializable

Hand-written serialization is clear for small models, but maintaining dozens of fields by hand invites typos and drift. For large or frequently changing models, generate the code with json_serializable. It needs one runtime dependency for the annotations and two dev dependencies for the build.

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

Annotate the class with @JsonSerializable(), add the part directive pointing at the file the generator will create, and wire fromJson / toJson to the generated _$UserFromJson and _$UserToJson functions. The part filename must match the source file: user.dart declares part 'user.g.dart';.

// user.dart
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';   // matches this file's name

@JsonSerializable()
class User {
  final int id;
  final String name;

  User({required this.id, required this.name});

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

Run the builder from the project root. The --delete-conflicting-outputs flag clears stale generated files so the build does not stall on a leftover .g.dart:

dart run build_runner build --delete-conflicting-outputs

That writes user.g.dart with the boilerplate filled in. Re-run the command after every change to an annotated model, or use dart run build_runner watch to regenerate on save. Nested classes and lists generate automatically as long as each nested type is itself @JsonSerializable(). The trade-off is a build step and an extra dependency, so reserve it for when the model count makes hand-writing painful.

Common mistakes

  • Reading raw maps everywhere. Wrap JSON in a model so the app stays typed.
  • Casting a nullable field as non-null. Use as String? and a ?? default for optional keys.
  • Assuming int for numbers. Use (json['x'] as num).toDouble() when a double is expected.
  • Forgetting to map nested objects and lists. Call each child's fromJson and map over arrays.
  • Hand-writing huge models. Switch to json_serializable once they grow or change often.
  • Trusting a number to stay an int. The same field can arrive as 1 or 1.0; cast through num and call .toDouble() rather than as double.
  • Assuming keys always exist. A strict as String throws on a missing or null key — cast through a nullable type (as String?) and fall back with ??.
  • Skipping build_runner after editing an annotated model. The generated .g.dart goes stale; re-run dart run build_runner build --delete-conflicting-outputs (or use watch).

Frequently asked questions

How do I parse JSON in Flutter?

Decode the string with jsonDecode from dart:convert to get a Map or List, then pass it into a model's fromJson factory so the rest of your code uses typed objects.

What is the difference between fromJson and toJson?

fromJson is inbound — a factory that builds an object from a decoded map. toJson is outbound — a method that turns the object back into a map for jsonEncode. Most models have both.

How do I parse a JSON list into a list of objects?

Cast the decoded value to List, map each element through your model's fromJson, and toList(): (jsonDecode(body) as List).map((e) => User.fromJson(e)).toList().

Should I hand-write or generate JSON serialization?

Hand-write for small models — it is clear and has no build step. Generate with json_serializable once models grow large, nest deeply, or change often, to avoid error-prone boilerplate.

Further reads

Keep going with the tutorials that pair with this guide:

Sources: Flutter documentation — JSON and serialization; dart:convert jsonDecode/jsonEncode (docs.flutter.dev, api.dart.dev). Verified against current stable Flutter.