skip to content

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%

answer

  1. one annotation per class
  2. fieldRename: FieldRename.snake
  3. @JsonKey(name:) wins over fieldRename
  4. explicitToJson for nested toJson
  5. includeIfNull defaults to true

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.

solid answer

~40 s

Each 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 lines
dart
import '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

for a junior

Recall that fieldRename maps camelCase fields to snake_case keys and that nested classes need their own fromJson and toJson.

for a middle

Explain what explicitToJson changes in the generated toJson, the default of includeIfNull, and how nullable types, defaultValue and required differ for a missing key.

for a senior

Discuss how you keep models tolerant of optional data without hiding contract breaks, and how checked mode and payload tests make parse failures diagnosable.

for a principal

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