Flutter Tutorial

Flutter Intermediate Lesson 66 of 135 4 min read

JSON and Model Classes in Flutter

Turn JSON into typed Dart model classes in Flutter: fromJson, toJson, nested objects, null handling, and code generation tools.

On this page

A server sends text. Your app wants objects with typed fields. The step between them is the model class. The Dart lesson on JSON explains the basics; this one focuses on what matters in an app.

From JSON to a model

Given this response:

{
  "id": 42,
  "name": "Trail backpack",
  "price": 3499.5,
  "inStock": true,
  "tags": ["outdoor", "travel"],
  "seller": { "id": 7, "name": "Himal Gear" },
  "discountPercent": null
}
class Seller {
  const Seller({required this.id, required this.name});

  final int id;
  final String name;

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

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

class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.inStock,
    required this.tags,
    required this.seller,
    this.discountPercent,
  });

  final int id;
  final String name;
  final double price;
  final bool inStock;
  final List<String> tags;
  final Seller seller;
  final int? discountPercent;

  factory Product.fromJson(Map<String, dynamic> json) => Product(
        id: json['id'] as int,
        name: json['name'] as String,
        price: (json['price'] as num).toDouble(),
        inStock: json['inStock'] as bool? ?? false,
        tags: (json['tags'] as List<dynamic>? ?? []).cast<String>(),
        seller: Seller.fromJson(json['seller'] as Map<String, dynamic>),
        discountPercent: json['discountPercent'] as int?,
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'price': price,
        'inStock': inStock,
        'tags': tags,
        'seller': seller.toJson(),
        'discountPercent': discountPercent,
      };

  double get finalPrice =>
      discountPercent == null ? price : price * (1 - discountPercent! / 100);
}

The details that prevent crashes

SituationWrite
A number that may arrive as 10 or 10.5(json['price'] as num).toDouble()
A field that may be missing or nulljson['x'] as String?, and make the field nullable
A missing value with a sensible defaultjson['inStock'] as bool? ?? false
A list that may be missing(json['tags'] as List<dynamic>? ?? [])
A dateDateTime.parse(json['createdAt'] as String)
An enumStatus.values.byName(json['status'] as String)
A nested objectSeller.fromJson(json['seller'] as Map<String, dynamic>)

A cast such as as int on a missing or wrong-typed value throws at run time. APIs change, so be strict only about fields the app cannot work without.

A list of models

List<Product> parseProducts(String body) {
  final list = jsonDecode(body) as List<dynamic>;
  return [for (final item in list) Product.fromJson(item as Map<String, dynamic>)];
}

Many APIs wrap the list: {"data": [...], "total": 120}. Read the data key first.

Why not just use maps?

Text(product['nmae'])   // a typo: shows nothing, fails silently
Text(product.nmae)      // a typo: the code does not compile

Models give you autocomplete, compile-time checking, one place to handle odd data, and somewhere to put helpers such as finalPrice.

Immutable models and copyWith

Keep fields final. To change one, make a copy.

Product copyWith({String? name, double? price, bool? inStock}) => Product(
      id: id,
      name: name ?? this.name,
      price: price ?? this.price,
      inStock: inStock ?? this.inStock,
      tags: tags,
      seller: seller,
      discountPercent: discountPercent,
    );

final updated = product.copyWith(inStock: false);

Generating the code

Writing this by hand for fifty classes is slow and error-prone. Two packages generate it.

json_serializable writes fromJson and toJson:

flutter pub add json_annotation
flutter pub add --dev json_serializable build_runner
import 'package:json_annotation/json_annotation.dart';

part 'product.g.dart';

@JsonSerializable()
class Product {
  const Product({required this.id, required this.name, required this.price});

  final int id;
  final String name;

  @JsonKey(name: 'unit_price') // the JSON key differs from the field name
  final double price;

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
  Map<String, dynamic> toJson() => _$ProductToJson(this);
}
dart run build_runner build --delete-conflicting-outputs

freezed also generates copyWith, ==, hashCode and toString, and supports sealed unions. It is the common choice for larger apps.

Start by writing a few models by hand so you understand what the generators produce.

Parsing large responses

Decoding a very large JSON document takes long enough to make the UI stutter. Move it to a background isolate with compute.

import 'package:flutter/foundation.dart';

Future<List<Product>> fetchProducts() async {
  final response = await http.get(Uri.parse('$baseUrl/products'));
  return compute(parseProducts, response.body); // runs off the UI thread
}

parseProducts must be a top-level or static function. For small responses this is unnecessary.

Separate what the API sends from what the app uses

If the API’s shape is awkward or changes often, keep two classes: a DTO that mirrors the JSON exactly, and a clean domain model used by the rest of the app, with a function converting one to the other. Then an API change touches one file.

Try it yourself

Write model classes for a weather response containing a city name, a temperature that may be an int or a double, a list of hourly forecasts (each with a time and a temperature), and an optional “alert” text. Parse a sample JSON string and display it.

Practise in the playground Updated