With json_serializable, when do you reach for a JsonConverter class rather than @JsonKey(fromJson:, toJson:) to change a field's wire format?
answer
- one field versus a whole type
- static or top-level functions
- JsonConverter<T, S> with a const constructor
- annotate field, class or converters:
- applies inside List<T> too
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.
solid answer
~40 sFor one field whose wire format differs, such as a duration sent as milliseconds, `@JsonKey(fromJson: _msToDuration, toJson: _durationToMs)` is enough; both must be static or top-level functions and should round-trip. When the same rule applies to a type across many fields or classes, like every timestamp arriving as epoch milliseconds, I write a class implementing `JsonConverter<DateTime, int>` with a `const` constructor. Put on a field, it converts that field; put on the class, or listed in `@JsonSerializable(converters: [...])`, it applies to every `DateTime` field there, including elements of a `List<DateTime>`. The generator also wraps a non-nullable converter with a null check for a `DateTime?` field, so one converter covers both.
code
dart · 27 linesclass EpochMillisConverter implements JsonConverter<DateTime, int> {
const EpochMillisConverter();
@override
DateTime fromJson(int json) =>
DateTime.fromMillisecondsSinceEpoch(json, isUtc: true);
@override
int toJson(DateTime object) => object.millisecondsSinceEpoch;
}
const apiModel = JsonSerializable(
fieldRename: FieldRename.snake,
converters: [EpochMillisConverter()],
);
@apiModel
class Shipment {
Shipment({required this.shippedAt, this.deliveredAt, this.scans = const []});
final DateTime shippedAt;
final DateTime? deliveredAt;
final List<DateTime> scans;
factory Shipment.fromJson(Map<String, dynamic> json) => _$ShipmentFromJson(json);
Map<String, dynamic> toJson() => _$ShipmentToJson(this);
}go deeper
Recall that @JsonKey can take fromJson and toJson functions for one field, and that JsonConverter is the reusable alternative.
Explain where a JsonConverter can be attached, why it needs a const constructor, and how it handles collections and nullable fields.
Show how you enforce one wire format for a type across a codebase, and how you test converters for round-trip safety and time-zone handling.
Discuss whether wire-format quirks should be absorbed by client converters or fixed in the API contract, given several clients and app versions.
## The problem both tools solve `json_serializable` knows how to map the core types: `DateTime` as an ISO-8601 string, `Duration` as microseconds, enums by name, `Uri`, `BigInt` and so on. Real APIs often disagree: timestamps as epoch milliseconds, money as an integer count of minor units, a colour as a hex string. Two mechanisms let you override the mapping, and they differ mainly in **scope and reuse**. ## Field-level functions: @JsonKey(fromJson:, toJson:) - You pass two functions to the field's annotation: `fromJson` maps the JSON value to the field type, `toJson` maps back. - They **must be top-level or static functions** (or a constructor taking one positional argument), because annotation arguments are compile-time constants. - The package recommends setting both, and that the pair **round-trips**: `fromJson(toJson(x)) == x`. - They apply to that one field only. A second field with the same format needs the same annotation again. This is the right tool for a genuine one-off, such as a single `prepTime` field sent in milliseconds. ## Type-level rules: JsonConverter<T, S> `JsonConverter<T, S>` is an abstract class in `json_annotation` with two methods: `T fromJson(S json)` and `S toJson(T object)`. `T` is the Dart type, `S` the JSON-side type (`int`, `String`, `Map<String, dynamic>`...). Because an instance is used as an annotation, the implementation needs a **`const` constructor**. Where you can put it: 1. **On a field** (`@EpochMillisConverter() final DateTime placedAt;`): that field only. 2. **On the class** (`@EpochMillisConverter()` above `@JsonSerializable()`): every field of type `T` in the class. 3. **In `@JsonSerializable(converters: [EpochMillisConverter()])`**: equivalent to the class annotation, but the whole `JsonSerializable` value can be stored in a `const` and reused across many classes, which is how a codebase applies one timestamp rule everywhere. Two behaviours make converters more than a reusable function pair: - **Collections.** A converter for `DateTime` also converts `DateTime` elements inside `List<DateTime>` or map values; field-level functions receive the whole field value and would have to handle the list themselves. - **Nullability.** For a nullable field such as `DateTime? deliveredAt`, the generator wraps a non-nullable converter in a helper that passes `null` through and calls the converter otherwise, so one `JsonConverter<DateTime, int>` serves both `DateTime` and `DateTime?`. ## Choosing | Need | Use | |---|---| | One field, unusual format | `@JsonKey(fromJson:, toJson:)` | | One type, many fields or classes | `JsonConverter` on the class or in `converters:` | | A type inside `List<T>` or map values | `JsonConverter` | | A type you own and control | give the type its own `fromJson` / `toJson` | | A polymorphic value decided by its content | a `JsonConverter<Base, Map<String, dynamic>>` that inspects keys | The fourth row is easy to forget: if the type is yours, adding a `fromJson` factory and `toJson()` method to it needs no annotation at all, because the generator looks for those members. ## Worked example: an order API with epoch timestamps Suppose every timestamp in an order API is epoch milliseconds: `shipped_at`, an optional `delivered_at`, and a `scans` array of scan times. The hand-rolled route needs three annotations with slightly different functions (one for `DateTime`, one for `DateTime?`, one for `List<DateTime>`), each easy to get subtly wrong. The converter route needs one class, `EpochMillisConverter implements JsonConverter<DateTime, int>`, placed once in a shared `const JsonSerializable(converters: [...])` value that every order model uses as its annotation: - `shippedAt` goes through `fromJson` and `toJson` directly; - `deliveredAt` is wrapped by the generator's null-passing helper; - each element of `scans` is converted individually. When the API later switches to ISO-8601 strings, one class changes and every model follows after regeneration. ## Pitfalls - A converter that is not round-trip safe (epoch **seconds** in, **milliseconds** out) corrupts data silently on every save. - Time zones: `DateTime.fromMillisecondsSinceEpoch` returns local time unless you pass `isUtc: true`; decide once, in the converter. - A `JsonConverter` on a class applies to **every** field of that type; a field with a different format then needs its own field-level override. - Money as `double` loses precision; converting to an integer of minor units or a decimal type in the converter keeps arithmetic exact.
- Why must the converter have a const constructor?Because it is used as an annotation, and Dart annotations are compile-time constants. `@EpochMillisConverter()` is only valid if that constructor is `const`, and the same applies when the converter sits inside `JsonSerializable(converters: [...])`.
- Can a JsonConverter decide between subclasses of a polymorphic field?Yes. Implement `JsonConverter<Base, Map<String, dynamic>>` and inspect the map, for example which keys are present, to call the right subclass's `fromJson`. It is the fallback when the payload has no discriminator key a generator could switch on.
saying these in an interview costs you the question
- A JsonConverter only works when it is placed on an individual field
- @JsonKey(fromJson:) accepts an instance method or a closure
- A nullable DateTime? field needs a separate JsonConverter<DateTime?, int?>
- A converter for DateTime does not reach elements inside List<DateTime>
- A type you own always needs a converter to be serialised