With json_serializable, how do you map a nested order-history payload with snake_case keys and optional fields onto Dart models?
answer
- one annotation per class
- fieldRename: FieldRename.snake
- @JsonKey(name:) wins over fieldRename
- explicitToJson for nested toJson
- includeIfNull defaults to true
basics
~10 sAnnotate 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.
solid answer
~40 sEach class gets `@JsonSerializable(fieldRename: FieldRename.snake)` so `placedAt` maps to `placed_at` without per-field annotations; a key that does not follow the rule gets `@JsonKey(name: 'qty')`, which takes precedence. Nested models just need their own `fromJson`/`toJson`, and the generator calls `LineItem.fromJson` for each list element. On the way out I set `explicitToJson: true`, because by default the generated `toJson` puts the nested objects themselves into the map instead of calling their `toJson()`. For optional data: a nullable type when the key may be missing, `@JsonKey(defaultValue: ...)` when I want a fallback, and `includeIfNull: false` when the server should not receive `null` keys, because `includeIfNull` defaults to `true`.
code
dart · 39 linesimport 'package:json_annotation/json_annotation.dart';
part 'order.g.dart';
@JsonSerializable(
fieldRename: FieldRename.snake,
explicitToJson: true,
includeIfNull: false,
)
class Order {
Order({
required this.orderId,
required this.placedAt,
this.deliveryNote,
this.lineItems = const [],
});
final int orderId;
final DateTime placedAt;
final String? deliveryNote;
@JsonKey(defaultValue: <LineItem>[])
final List<LineItem> lineItems;
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class LineItem {
LineItem({required this.sku, required this.unitPrice, required this.quantity});
final String sku;
final double unitPrice;
@JsonKey(name: 'qty')
final int quantity;
factory LineItem.fromJson(Map<String, dynamic> json) => _$LineItemFromJson(json);
Map<String, dynamic> toJson() => _$LineItemToJson(this);
}go deeper
Recall that fieldRename maps camelCase fields to snake_case keys and that nested classes need their own fromJson and toJson.
Explain what explicitToJson changes in the generated toJson, the default of includeIfNull, and how nullable types, defaultValue and required differ for a missing key.
Discuss how you keep models tolerant of optional data without hiding contract breaks, and how checked mode and payload tests make parse failures diagnosable.
Weigh project-wide build.yaml defaults against per-class annotations, and how strict decoding should be when many app versions talk to one backend.
## The payload and the models `json_serializable` is a build-time generator: you annotate a class, run the generator, and it writes `_$OrderFromJson` and `_$OrderToJson` into a `part` file. The annotations come from `json_annotation`. Take an order-history endpoint that returns this: ```json { "orders": [ { "order_id": 981, "placed_at": "2026-09-01T10:15:00Z", "delivery_note": null, "line_items": [{ "sku": "A-12", "unit_price": 4.5, "qty": 2 }] } ], "next_cursor": null } ``` Three classes map it: `OrderHistory`, `Order` and `LineItem`. Each has a `factory X.fromJson(Map<String, dynamic> json) => _$XFromJson(json);` and a `Map<String, dynamic> toJson() => _$XToJson(this);`. ## Renaming keys - **`fieldRename`** on `@JsonSerializable` sets a naming strategy for every field in the class. `FieldRename.snake` turns `placedAt` into `placed_at`; the other values are `none` (the default), `kebab`, `pascal` and `screamingSnake`. - **`@JsonKey(name: 'qty')`** on one field overrides the strategy for that field. The package documents that `JsonKey.name` takes precedence over `fieldRename`, so a field called `quantity` can still read `qty`. - Project-wide defaults can live in the generator's `build.yaml` options (`field_rename: snake`), so individual classes carry only their exceptions. ## Nested objects and lists When a field's type has a `fromJson` factory, the generator uses it. For `List<LineItem> lineItems` it emits a map over the decoded list that calls `LineItem.fromJson(e as Map<String, dynamic>)` on each element; a nullable nested object gets a null check first. The nested class does not even need to be annotated, only to have the two methods. Serialisation is where people get surprised. With the default **`explicitToJson: false`**, the generated `toJson` writes `'line_items': instance.lineItems`, a list of `LineItem` objects. `jsonEncode` still produces correct JSON, because it calls `toJson()` on objects it cannot encode. But the map returned by `toJson()` is not plain data: printing it shows `Instance of 'LineItem'`, comparing it in a test fails, and storing it where only maps, lists and primitives are accepted breaks. **`explicitToJson: true`** makes the generator call `toJson()` on nested objects, so the map is JSON all the way down. ## Optional and missing fields | Situation | What to write | Generated behaviour | |---|---|---| | Key may be absent or null | nullable type, e.g. `String? deliveryNote` | reads `null`, no error | | Key may be absent, want a fallback | `@JsonKey(defaultValue: <LineItem>[])` | uses the default when missing or null | | Key must be present | non-nullable type | a `TypeError` from the cast if missing | | Key must be present, clear error | `@JsonKey(required: true)` | `MissingRequiredKeysException` | | Omit nulls when sending | `includeIfNull: false` | the key is left out of `toJson` | A few details worth knowing: 1. **`includeIfNull` defaults to `true`**: without it the request body carries `"delivery_note": null`. A per-field `@JsonKey(includeIfNull: ...)` overrides the class setting. 2. **`checked: true`** wraps any failure in a `CheckedFromJsonException` that names the key and class, which makes crash reports readable. 3. **Numbers** are decoded through `num` (`(json['qty'] as num).toInt()`), so `2.0` is accepted for an `int` field and a fractional value is truncated. 4. **`DateTime`** is supported out of the box as an ISO-8601 string; other wire formats need a converter. ## Reading the generated code Opening the `.g.dart` file is the fastest way to answer "what will this annotation do?". For the `Order` class above, the current generator emits, among others: - `orderId: (json['order_id'] as num).toInt()`: the snake_case key and the `num` cast; - `placedAt: DateTime.parse(json['placed_at'] as String)`: the built-in ISO-8601 handling; - `lineItems: (json['line_items'] as List<dynamic>?)?.map((e) => LineItem.fromJson(e as Map<String, dynamic>)).toList() ?? []`-style code for the defaulted list; - in `toJson`, a null-aware map entry such as `'delivery_note': ?instance.deliveryNote`, which is how `includeIfNull: false` leaves the key out; - with `explicitToJson: true`, `'line_items': instance.lineItems.map((e) => e.toJson()).toList()`. The file is regenerated, never edited: a change to an annotation only takes effect after the generator runs again, and a stale `.g.dart` is a classic reason why a rename "did nothing". ## Freezed on top With `freezed`, the same options apply: `@JsonKey` goes on constructor parameters, and `@JsonSerializable(explicitToJson: true)` can be placed on the factory constructor. Freezed's own README notes that nested lists of freezed objects need `explicitToJson`.
- If jsonEncode already calls toJson on nested objects, why does explicitToJson matter?Because `toJson()` itself returns a map that still holds `LineItem` instances. Anything that consumes that map directly, such as a test comparing it with an expected map, a cache that accepts only primitives, or code that inspects the map, sees objects rather than JSON. `explicitToJson: true` makes the map plain data.
- What is the difference between @JsonKey(required: true) and a non-nullable field when the key is missing?Both reject the payload. A non-nullable field fails on the cast with a generic `TypeError`. `required: true` checks the key's presence first and throws `MissingRequiredKeysException` naming it. It checks only presence: a key present with a `null` value passes that check.
- How would you omit null values from a PATCH body but still send an explicit null to clear one field?Set `includeIfNull: false` on the class so unset fields disappear, and for the field that needs tri-state semantics use `@JsonKey(explicitJsonNullWhenNonNullField: true)`, added in json_serializable 6.14.0. That setting omits the key when the Dart field is `null`, but writes `"key": null` when the field holds a non-null value whose serialisation is JSON `null`, such as a wrapper or sentinel meaning 'clear'.
saying these in an interview costs you the question
- fieldRename overrides a name given with @JsonKey(name:)
- Without explicitToJson, jsonEncode produces broken JSON for nested objects
- includeIfNull defaults to false, so null fields are dropped automatically
- A nested class must also be annotated with @JsonSerializable to be decoded
- Making the list nullable is the only way to tolerate a missing line_items key