skip to content

JSON-Mapped Models

API payloads become typed Dart models via fromJson factories and toJson, generated by json_serializable or freezed. Interviewers probe snake_case renaming, nested objects and polymorphic payloads.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

6

In a Flutter app, how do you turn a decoded JSON API response into a typed Dart model, and why not pass Map<String, dynamic> around?

level: juniorimportance: must knowfreq 74%

answer

  1. one typed boundary per payload
  2. factory Model.fromJson(Map<String, dynamic>)
  3. cast each key with as
  4. toJson returns a plain map
  5. no reflection: tree shaking

basics

~20 s

Give the model class a factory fromJson(Map<String, dynamic>) that casts each key into a typed final field, and a toJson() that builds the map back. Raw maps push every typo and type mismatch to runtime, far from the API call.

solid answer

~40 s

The decoded body is a `Map<String, dynamic>`, so I convert it once, at the edge, into a model: a `factory OrderSummary.fromJson(Map<String, dynamic> json)` that reads each key and casts it (`json['id'] as int`, `json['note'] as String?`) into `final` fields, plus a `toJson()` that returns the map for requests or caching. After that the app uses `order.total` with autocompletion and compile-time checks, and an API change is fixed in one file. A missing or mistyped key fails inside `fromJson` with a `TypeError`, not three widgets later. Flutter has no reflection (`dart:mirrors` is unavailable because it would defeat tree shaking), so the mapping is either hand-written, which suits a few small models, or generated by `json_serializable` or `freezed`.

code

dart · 5 lines
dart
Future<OrderSummary> fetchOrder(http.Client client, int id) async {
  final response = await client.get(Uri.parse('https://api.example.com/orders/$id'));
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  return OrderSummary.fromJson(json);
}

go deeper

for a junior

Be ready to write a fromJson factory and a toJson method from memory, with casts for each field and a nullable type for optional keys.

for a middle

Explain which exception a bad payload produces and where, why numbers go through num, and when to switch from hand-written mapping to a generator.

for a senior

Talk about where the boundary lives in the architecture, how you test models against recorded payloads, and how parse failures reach the user as a domain error.

for a principal

Frame the choice between hand-written and generated models as a team cost: build-time codegen, review noise in generated files, and consistency across many models.

## Where the model sits A Flutter app talks to a backend in JSON, and once the body has been decoded the app holds nothing but a **`Map<String, dynamic>`** (or a `List<dynamic>` of them). Every value in that map is `dynamic`: the compiler does not know that `id` is an `int` or that `note` may be missing. The standard practice is to convert that map **once, at the boundary** (in the repository or API-client layer) into a **model class** with typed, usually `final`, fields. Widgets, state classes and tests then see only the model. ## Writing fromJson and toJson by hand The convention, used by the Flutter docs and by every code generator, is a pair of members on the model: - a **`fromJson` factory** (or named constructor) that takes `Map<String, dynamic>` and returns an instance; - a **`toJson()` method** that returns `Map<String, dynamic>` for request bodies or a local cache. ```dart class OrderSummary { const OrderSummary({ required this.id, required this.total, required this.placedAt, this.note, }); final int id; final double total; final DateTime placedAt; final String? note; factory OrderSummary.fromJson(Map<String, dynamic> json) => OrderSummary( id: json['id'] as int, total: (json['total'] as num).toDouble(), placedAt: DateTime.parse(json['placed_at'] as String), note: json['note'] as String?, ); Map<String, dynamic> toJson() => { 'id': id, 'total': total, 'placed_at': placedAt.toIso8601String(), if (note != null) 'note': note, }; } ``` Three details carry most of the correctness: 1. **Casts decide failure mode.** `json['id'] as int` throws a `TypeError` when the key is absent (`null` is not an `int`). That is the point: bad data fails at the boundary. 2. **Optional means nullable.** A key the API really omits gets a nullable type and an `as String?` cast; a key it always sends stays non-nullable. 3. **Numbers go through `num`.** JSON has one number type. On native platforms the decoder yields an `int` for `12` and a `double` for `12.5`, so `as double` breaks on a whole number; `(json['total'] as num).toDouble()` accepts both. `json_serializable` generates exactly this pattern. ## What a typed model buys you | Concern | Raw `Map<String, dynamic>` | Typed model | |---|---|---| | Typo in a key | silent `null` at runtime | caught once, inside `fromJson` | | Wrong type | crash wherever it is used | `TypeError` at the boundary | | IDE support | none | autocompletion, rename refactoring | | API change | grep every screen | edit one class | | Tests | build nested maps | construct the model directly | A model also gives you a place for **equality** and **`copyWith`** (by hand, or generated by `freezed`), which state libraries rely on to decide whether anything changed. ## Why Flutter has no reflection-based mapper In some ecosystems a library walks a class's fields at runtime and fills them from JSON automatically. Flutter cannot do that: runtime reflection (`dart:mirrors`) is **disabled in Flutter**, because reflection makes every member potentially used and so defeats **tree shaking**, the release-build step that strips unused code. The mapping must therefore exist as ordinary Dart code, written by you or by a code generator that runs at build time. ## Hand-written or generated - **Hand-written** mapping is fine for a handful of small, stable models, and it is the best way to understand what the generators emit. - **Generated** mapping (`json_serializable`, or `freezed` on top of it) pays off once models are nested, numerous or renamed from `snake_case`, because the boilerplate and its typos disappear. - Either way, the model has **unit tests** that decode a recorded payload and round-trip it through `toJson`, since a mismatch is otherwise only discovered at runtime. ## Testing the model Because the compiler cannot see inside a JSON payload, tests are the only thing that proves the mapping matches the API: - keep a **recorded response** (a fixture file copied from the real API) and assert that `fromJson` produces the expected field values; - **round-trip** it: `OrderSummary.fromJson(order.toJson())` should equal the original, which catches a key spelled differently in the two directions; - add a **malformed payload** case (a missing key, a string where a number belongs) and assert that parsing throws, so the failure mode is deliberate rather than accidental; - when the backend team changes a response, the fixture is updated in the same change, and the test shows exactly which models are affected. These tests are plain Dart unit tests: no widget, device or network is needed, so they run in milliseconds. Decoding the string itself (`jsonDecode`) is a `dart:convert` concern, and running the build-time generators is a tooling concern; the model and its two methods are the part every Flutter networking layer shares.

  • Why read a price with (json['total'] as num).toDouble() rather than as double?
    JSON has a single number type. On native platforms the decoder returns an `int` for `12` and a `double` for `12.5`, so `as double` throws a `TypeError` on a whole-number price. Casting to `num` and calling `toDouble()` accepts both, and it is the pattern `json_serializable` generates for `double` fields.
  • What happens when a required key is missing from the payload?
    `json['id']` evaluates to `null`, and `null as int` throws a `TypeError` inside `fromJson`. Catch it at the repository boundary and map it to a domain error the UI can show. Make a field nullable only when the API genuinely omits it, not to hide the error.
  • Do you have to call toJson() yourself before encoding a model?
    No. `jsonEncode` calls `toJson()` on any object it cannot encode directly, so `jsonEncode(order)` works. Call `toJson()` explicitly only when you need the map itself, for example to store it or compare it in a test.

A typed model is a customs desk at the border: every parcel (the raw map) is opened and checked once on arrival, so nothing inside the country has to wonder what is in the box.

saying these in an interview costs you the question

  • Passing Map<String, dynamic> into widgets is fine because it stays flexible
  • Flutter can map JSON onto any class at runtime through reflection
  • Making every field nullable is a safe way to avoid parse errors
  • json['total'] as double works for every numeric value in JSON
  • fromJson has to be generated; hand-written mapping is not allowed
open as a page

With json_serializable, how do you map a nested order-history payload with snake_case keys and optional fields onto Dart models?

level: middleimportance: must knowfreq 62%

basics

~10 s

Annotate each class with @JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true), give absent keys nullable types or @JsonKey(defaultValue:), use @JsonKey(name:) for odd keys, and set includeIfNull: false to omit nulls from toJson.

open as a page

With json_serializable, how do you decode a generic response envelope such as Page<T>, and what does genericArgumentFactories generate?

level: middleimportance: should knowfreq 34%

basics

~10 s

Set @JsonSerializable(genericArgumentFactories: true) on Page<T>; the generated fromJson then takes a T Function(Object? json) fromJsonT and toJson takes an Object? Function(T value) toJsonT, which the caller supplies, for example Order.fromJson.

open as a page

With json_serializable, when do you reach for a JsonConverter class rather than @JsonKey(fromJson:, toJson:) to change a field's wire format?

level: middleimportance: should knowfreq 38%

basics

~10 s

@JsonKey(fromJson:, toJson:) suits a one-off field and takes static or top-level functions. A JsonConverter<T, S> is reusable: annotate a field, a whole class or JsonSerializable(converters:), and it also converts T inside collections.

open as a page

In a Flutter app, the order-history screen skips frames while a multi-megabyte JSON response is decoded into models; how do you move that work off the UI isolate?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Put jsonDecode and the fromJson mapping into one top-level function that takes the response body String, and run it with compute() or Isolate.run. Decoding alone off-isolate is not enough if fromJson still runs on the UI isolate.

open as a page

With freezed, how do you deserialize a polymorphic JSON payload whose type field selects the variant, and what happens when the server sends an unknown type?

level: seniorimportance: should knowfreq 40%

basics

~10 s

Declare a sealed freezed class with one factory constructor per variant, set @Freezed(unionKey: 'type'), map values with unionValueCase or @FreezedUnionValue, and name a catch-all with fallbackUnion, because an unknown value otherwise throws CheckedFromJsonException.

open as a page