How to Convert JSON to Dart Classes (fromJson, toJson, Null Safety)

Dart has no reflection-based JSON binding, so every model you decode needs explicit fromJson and toJson code. For a Flutter app talking to an API, writing that by hand for each response is slow and error-prone, especially once nesting and null safety are involved. Generating the classes from a real sample gets the types, the nullability, and the serialization right in one step. This guide covers how the mapping works, the fastest way to do it, and the Dart-specific gotchas.

The fastest route: a browser generator

For a one-off, paste the JSON into the JSON to Dart converter. It reads the sample, infers the type of every field, and writes classes with a fromJson factory and a toJson method: nested objects become their own classes, arrays become List types decoded with a .map() call, and numbers resolve to int or double. Because it runs entirely in your browser, the JSON never leaves your machine, which matters when the sample is a real production response.

If the JSON will not convert, it is almost always invalid rather than genuinely unconvertible. Run it through the JSON parser to find the exact syntax error, or format it so you can see the structure first.

How JSON maps to Dart

A generator makes the same decisions every time. Knowing them tells you what to expect.

Here is the shape of a simple conversion:

{ "id": 1, "name": "Ada" }

becomes:

class Root {
  final int id;
  final String name;

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

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

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

Why fromJson and toJson are written out

Languages like Java or C# can use a reflection-based library (Jackson, System.Text.Json) that reads your fields at runtime. Dart deliberately avoids runtime reflection in production, so there is no automatic binding: each class carries its own fromJson that reads keys out of a Map<String, dynamic> with explicit casts, and its own toJson that writes them back. That is why a generated Dart model is more code than the same model in other languages, and why generating it saves the most time.

Null safety: required versus nullable

Dart is null-safe, so the generator has to decide which fields can be null. A field becomes nullable (Type?) when its key is missing from some objects in an array, or its value is sometimes null; otherwise it is non-nullable and marked required in the constructor:

final String sku;      // always present
final int? qty;        // missing in some records

Item({required this.sku, this.qty});

This is why you should paste a representative sample with several records: a single object makes every field look required, so the generator cannot tell which are actually optional, and a wrong guess means a runtime cast error when a key is missing.

build_runner versus hand-written

Flutter’s other common approach is json_serializable with build_runner, which generates the fromJson/toJson into a .g.dart part file from annotations. That is worth it on a large app with many models, because it stays in sync as you edit the class. For a quick model, a one-off, or learning, hand-written methods, which is what this tool produces, have no build step and nothing to run. You can always add annotations later if you move to build_runner.

When you see dynamic

A field that is only ever null, an empty array, or holds more than one unrelated type has no single Dart type, so it falls back to dynamic (or List<dynamic> for a mixed array). That is a lack of evidence, not a bug. Populate those fields in the sample to get precise types, and reach for the JSON viewer to confirm the shape of a deeply nested payload before you convert.

Using the classes

Decoding is two steps: parse the string, then build the model.

import 'dart:convert';

final map = jsonDecode(source) as Map<String, dynamic>;
final order = Root.fromJson(map);

// and back to JSON
final text = jsonEncode(order.toJson());

For a top-level JSON array, decode into the element type with a map: (jsonDecode(source) as List).map((e) => Item.fromJson(e as Map<String, dynamic>)).toList().

The gotchas that bite

Frequently asked questions

How do I convert JSON to a Dart class?
Paste the JSON into the JSON to Dart converter and it writes classes with fromJson and toJson, inferring nested classes, List types, int/double, and nullable fields. Then call Root.fromJson(jsonDecode(source) as Map<String, dynamic>).

Does it use json_serializable or build_runner?
No. It writes the fromJson and toJson methods directly, so there is no build step. You can add json_serializable annotations later if your project uses build_runner.

How does it decide which fields are nullable?
A field is nullable when its key is missing from some records in the sample or its value is sometimes null; otherwise it is required. Paste several records so the generator sees which keys are not always present.

Why does my Dart cast throw “type int is not a subtype of double”?
The value arrived as an integer but the field is typed double. Use (json['x'] as num).toDouble(), or make sure the sample shows the field with a decimal so the generator types it correctly.

For the format underneath all this, see the guide on what JSON is, the same idea for iOS in converting JSON to Swift, and the roundup of free JSON tools for the rest of the toolkit.

Leave a Reply

Your email address will not be published. Required fields are marked *