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?
answer
- one typed boundary per payload
- factory Model.fromJson(Map<String, dynamic>)
- cast each key with as
- toJson returns a plain map
- no reflection: tree shaking
basics
~20 sGive 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 sThe 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 linesFuture<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
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.
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.
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.
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