Under sound null safety, a Dart model says which fields are always there and which can be missing. That is the hard part of turning JSON into Dart: the class itself is boilerplate, but every ? you leave out is a _TypeError waiting for the first response that does not match your sample. This guide builds a model from a real-looking API response, shows how to decide between required and nullable fields, and covers numbers, nested lists and dates.
Every example and error message was run on Dart 3.13.7 (the Dart VM), and the code passes dart analyze with the recommended lints. The generated classes come from the JSON to Dart generator on this site. The package examples use json_serializable 6.15.2, json_annotation 4.12.0, freezed 4.0.3, freezed_annotation 3.1.0 and build_runner 2.17.1.
Start from several real items, not one
A generator can only infer types from what you paste. Here are two items from a product list, copied as they came from the API:
[
{
"id": 101,
"name": "Desk lamp",
"price": 24.5,
"discount": null,
"tags": ["home", "lighting"],
"seller": { "id": 7, "name": "Lumen Co" },
"variants": [{ "sku": "DL-W", "stock": 3 }],
"created_at": "2026-10-28T07:00:00Z"
},
{
"id": 102,
"name": "USB-C cable",
"price": 5,
"discount": 0.15,
"tags": [],
"seller": { "id": 9, "name": "Wired", "verified": true },
"variants": [{ "sku": "UC-1M", "stock": 0, "color": "black" }],
"created_at": "2026-10-30T02:15:00Z",
"note": "Ships in 2 days"
}
]Generate the model from the second item alone and it looks reasonable, but it is wrong in four places. It contains these lines:
price: json['price'] as int, // 5 looked like an int
discount: (json['discount'] as num).toDouble(), // never null in this item
tags: json['tags'] as List<dynamic>, // [] says nothing about the items
note: json['note'] as String, // missing from other itemsDecoding the first item with that model fails on the price:
type 'double' is not a subtype of type 'int' in type castChange price to a double and it fails on the next field instead, because the first item's discount is null:
type 'Null' is not a subtype of type 'num' in type castFixing these one crash at a time is slow. Paste both items as an array instead. The generator merges every object it finds in the array, so a key that is missing from some items, or null in any of them, becomes nullable, and a number that is whole in one item and fractional in another becomes a double. Use as many real responses as you can get: the empty list, the unpaid order, the user without an avatar.
The generated class
With both items pasted and the root class named Product, the Plain Dart style produces this (plus Seller and Variant classes in the same style, where verified and color are nullable because only one item has them):
class Product {
final int id;
final String name;
final double price;
final double? discount;
final List<String> tags;
final Seller seller;
final List<Variant> variants;
final String createdAt;
final String? note;
const Product({
required this.id,
required this.name,
required this.price,
this.discount,
required this.tags,
required this.seller,
required this.variants,
required this.createdAt,
this.note,
});
factory Product.fromJson(Map<String, dynamic> json) => Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
discount: (json['discount'] as num?)?.toDouble(),
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
seller: Seller.fromJson(json['seller'] as Map<String, dynamic>),
variants: (json['variants'] as List<dynamic>).map((e) => Variant.fromJson(e as Map<String, dynamic>)).toList(),
createdAt: json['created_at'] as String,
note: json['note'] as String?,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price': price,
'discount': discount,
'tags': tags,
'seller': seller.toJson(),
'variants': variants.map((e) => e.toJson()).toList(),
'created_at': createdAt,
'note': note,
};
}Both items decode with it, and jsonEncode(product.toJson()) gives back the same keys. Two things change on the way back: "price": 5 becomes 5.0, and keys that were missing come back as null. Most servers accept both; the sections below show how to change them if yours does not.
Required or nullable: the rules
| In your samples, the key is | Field type |
|---|---|
| Present with a value in every item | required String name |
| Missing from some items | String? note |
null in some items | double? discount |
null in every item | dynamic: the type is unknown, so look it up and replace it |
An empty list [] in every item | List<dynamic>: replace with the real item type |
Samples only show what happened, not what the API promises. If the API documentation says a field is optional, make it nullable even if every sample has it. When you cannot tell, the tool's Make all fields nullable option makes every field nullable; that never crashes in fromJson, but every read then needs a null check.
A missing key and a null value look the same
Reading a missing key from a Map returns null, exactly like a key whose value is JSON null. Only containsKey tells them apart:
final missing = jsonDecode('{"id": 1}') as Map<String, dynamic>;
final explicit = jsonDecode('{"id": 1, "note": null}') as Map<String, dynamic>;
print(missing['note']); // null
print(explicit['note']); // null
print(missing.containsKey('note')); // false
print(explicit.containsKey('note')); // trueA String? field covers both cases. You only need containsKey for PATCH-style APIs where "not sent" means "leave it alone" and null means "clear it".
Default values instead of null
When a missing value has an obvious meaning, such as an empty list or zero stock, a default keeps the field non-nullable:
final stock = json['stock'] as int? ?? 0;
final tags = (json['tags'] as List<dynamic>? ?? const [])
.map((e) => e as String)
.toList();Do not use a default to hide a field that should always be there. An id of 0 or a name of '' moves the bug from fromJson, where the stack trace points at it, to some screen much later.
Why late does not help
late tells the compiler "trust me, this will be set before it is read". For JSON fields that is the same promise as a non-nullable type, checked later and further from the cause:
class Order {
late final String note;
Order.fromJson(Map<String, dynamic> json) {
if (json['note'] != null) note = json['note'] as String;
}
}
void main() {
final order = Order.fromJson({'id': 1});
print(order.note);
// LateInitializationError: Field 'note' has not been initialized.
}If a value can be absent, say so with String?. Keep late for fields your own code sets after construction.
int vs double
JSON has one number type, but jsonDecode picks a Dart type from how the number is written:
final json = jsonDecode('{"a": 1, "b": 1.0, "c": 1e3}') as Map<String, dynamic>;
json['a'].runtimeType; // int
json['b'].runtimeType; // double
json['c'].runtimeType; // double (1000.0)So json['price'] as double works for 24.5 and throws type 'int' is not a subtype of type 'double' the day the price is 5. Read any field that can be fractional through num:
price: (json['price'] as num).toDouble(),
discount: (json['discount'] as num?)?.toDouble(),The generator does this for every field that has a decimal point or an exponent in any sample. It reads the numbers from the text you paste, so 1.0 counts as a double even though your browser's JSON.parse would turn it into 1. A field that is a whole number in every sample stays int; if you know it can be fractional (prices, ratings, coordinates), change it to double by hand. On Flutter web the wrong cast can pass in the browser and only fail on a phone; the Flutter JSON type errors guide shows why.
Nested objects and lists of objects
jsonDecode returns nested objects as Map<String, dynamic> and arrays as List<dynamic>, never as your classes. Each nested object needs its own fromJson call, and each list needs a map over its items:
seller: Seller.fromJson(json['seller'] as Map<String, dynamic>),
variants: (json['variants'] as List<dynamic>)
.map((e) => Variant.fromJson(e as Map<String, dynamic>))
.toList(),
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),Casting the list directly, as in json['tags'] as List<String>, fails even when every item is a string, because the list itself is a List<dynamic>. When the nested object or the list can be null or missing, the generator checks before converting:
seller: json['seller'] == null ? null : Seller.fromJson(json['seller'] as Map<String, dynamic>),
variants: (json['variants'] as List<dynamic>?)?.map((e) => Variant.fromJson(e as Map<String, dynamic>)).toList(),
// and in toJson:
'seller': seller?.toJson(),
'variants': variants?.map((e) => e.toJson()).toList(),toJson must call toJson() on nested models too. Without it, jsonEncode receives a Seller object it cannot encode.
Dates and other hand edits
The generator leaves created_at as a String, because it cannot tell which strings are dates or which time zone the API means. Change date fields by hand. Here is an Order model after the usual edits: a DateTime, an optional date, a default list, and toJson leaving out null values instead of sending them:
class Order {
final int id;
final double total;
final DateTime createdAt;
final DateTime? paidAt;
final String? note;
final List<String> tags;
const Order({
required this.id,
required this.total,
required this.createdAt,
this.paidAt,
this.note,
this.tags = const [],
});
factory Order.fromJson(Map<String, dynamic> json) => Order(
id: json['id'] as int,
total: (json['total'] as num).toDouble(),
createdAt: DateTime.parse(json['created_at'] as String),
paidAt: json['paid_at'] == null
? null
: DateTime.parse(json['paid_at'] as String),
note: json['note'] as String?,
tags: (json['tags'] as List<dynamic>? ?? const [])
.map((e) => e as String)
.toList(),
);
Map<String, dynamic> toJson() => {
'id': id,
'total': total,
'created_at': createdAt.toUtc().toIso8601String(),
'paid_at': ?paidAt?.toUtc().toIso8601String(),
'note': ?note,
'tags': tags,
};
}With {"id": 7, "total": 30, "created_at": "2026-10-28T14:00:00+07:00"}, total is 30.0, createdAt is 2026-10-28 07:00:00.000Z, and toJson gives {"id":7,"total":30.0,"created_at":"2026-10-28T07:00:00.000Z","tags":[]}. Without created_at, fromJson throws type 'Null' is not a subtype of type 'String' in type cast, which is what you want for a field that must be there.
'note': ?note is a null-aware map entry: the key is left out when the value is null. It needs Dart 3.8 or later in your pubspec.yaml SDK constraint; on older versions, write if (note != null) 'note': note. For Unix timestamps, offsets and FormatException: Invalid date format, see parse DateTime from JSON.
Plain Dart, json_serializable or freezed
The JSON to Dart tool writes all three styles from the same input. They differ in what you maintain by hand:
| Style | What you get | Cost |
|---|---|---|
| Plain Dart | Classes with hand-readable fromJson and toJson | No packages; you edit the casts yourself when the API changes |
| json_serializable | Annotated classes; fromJson and toJson are generated into a .g.dart part file | json_annotation, plus json_serializable and build_runner as dev dependencies, and a build step after every model change |
| freezed | Immutable classes with value equality, copyWith and toString, using json_serializable for JSON | The same build step, plus two more packages |
With json_serializable, the null-safety decisions move into annotations. This version of the Order model uses a default list and leaves out null values:
import 'package:json_annotation/json_annotation.dart';
part 'order.g.dart';
@JsonSerializable(includeIfNull: false)
class Order {
final int id;
final double total;
@JsonKey(name: 'created_at')
final DateTime createdAt;
final String? note;
@JsonKey(defaultValue: <String>[])
final List<String> tags;
const Order({
required this.id,
required this.total,
required this.createdAt,
this.note,
required this.tags,
});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}dart run build_runner build generates this fromJson:
Order _$OrderFromJson(Map<String, dynamic> json) => Order(
id: (json['id'] as num).toInt(),
total: (json['total'] as num).toDouble(),
createdAt: DateTime.parse(json['created_at'] as String),
note: json['note'] as String?,
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
);It is the same code you would write by hand, so the same rules apply: a missing created_at throws the same type 'Null' is not a subtype of type 'String' in type cast. Two differences from the plain version: int fields are read through num, so 1.0 becomes 1 instead of throwing, and the generated toJson writes createdAt.toIso8601String() without converting to UTC first.
Pick plain Dart for a handful of models or a package that should have no dependencies. Pick json_serializable when you have many models and the API changes often, since re-running the build is less work than editing casts. Pick freezed when you also want copyWith and ==, for example for state objects. In a check with the freezed output for the product sample, two products decoded from the same JSON compared equal, and product.copyWith(price: 19.99) changed only the price. Plain classes compare by identity unless you write == and hashCode yourself.
The tool page lists the exact dart pub add commands and the invalid_annotation_target setting that freezed needs.
A workflow that holds up
- Collect several real responses, including edge cases: empty lists, optional fields left out,
nullvalues. Check they are valid JSON in the JSON formatter; a Flutter log of aMapis not JSON. - Paste the items as one array into JSON to Dart, name the root class, and pick a style. The input stays in your browser.
- Review the output: replace
dynamicandList<dynamic>, turn date strings intoDateTime, change whole-number fields that can be fractional todouble, and make fields the API documents as optional nullable. - Save the sample responses as test fixtures and decode them in a
flutter testordart test. When the backend changes, a failing test is cheaper than a red screen.
When a model still fails at runtime, the Flutter JSON type errors guide lists each is not a subtype of type message and its fix.