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 #
| Situation | Write |
|---|---|
A number that may arrive as 10 or 10.5 | (json['price'] as num).toDouble() |
| A field that may be missing or null | json['x'] as String?, and make the field nullable |
| A missing value with a sensible default | json['inStock'] as bool? ?? false |
| A list that may be missing | (json['tags'] as List<dynamic>? ?? []) |
| A date | DateTime.parse(json['createdAt'] as String) |
| An enum | Status.values.byName(json['status'] as String) |
| A nested object | Seller.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.