Dart Tutorial

Flutter Lesson 47 of 83 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 by Santosh Adhikari