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?
answer
- one factory constructor per variant
- default key is runtimeType
- unionKey, unionValueCase, @FreezedUnionValue
- unknown value: CheckedFromJsonException
- fallbackUnion names a catch-all
basics
~10 sDeclare 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.
solid answer
~40 sA freezed union is a `sealed` class with one factory constructor per variant plus a `fromJson` factory using `=>`. The generated `fromJson` switches on a discriminator key, `runtimeType` by default, whose value is the constructor name. For a real API I set `@Freezed(unionKey: 'type')`, adjust spelling with `unionValueCase: FreezedUnionCase.snake` or a per-variant `@FreezedUnionValue('refund_issued')`, and the generated `toJson` writes the same key back. Without a fallback, an unrecognised value hits the `default` branch, which throws `CheckedFromJsonException`, so one new event type from the server breaks parsing of the whole list. `fallbackUnion: 'unknown'` routes those values to an `unknown` constructor instead. Callers then use a Dart 3 `switch`; freezed 3 removed the generated `when`/`map`.
code
dart · 26 linesimport 'package:freezed_annotation/freezed_annotation.dart';
part 'order_event.freezed.dart';
part 'order_event.g.dart';
@Freezed(unionKey: 'type', fallbackUnion: 'unknown')
sealed class OrderEvent with _$OrderEvent {
const factory OrderEvent.placed({required int id, required DateTime at}) =
OrderPlaced;
const factory OrderEvent.shipped({required int id, required String carrier}) =
OrderShipped;
@FreezedUnionValue('refund_issued')
const factory OrderEvent.refunded({required int id, required int amount}) =
OrderRefunded;
const factory OrderEvent.unknown() = UnknownOrderEvent;
factory OrderEvent.fromJson(Map<String, dynamic> json) =>
_$OrderEventFromJson(json);
}
String describe(OrderEvent event) => switch (event) {
OrderPlaced(:final id) => 'Order $id placed',
OrderShipped(:final carrier) => 'Shipped with $carrier',
OrderRefunded(:final amount) => 'Refunded $amount cents',
UnknownOrderEvent() => 'Order updated',
};go deeper
Recall that a freezed union has one factory constructor per variant and that fromJson picks the variant from a key in the JSON.
Explain unionKey and its runtimeType default, how unionValueCase and FreezedUnionValue set the values, and how toJson writes the key back.
Argue for fallbackUnion from the version-skew angle: what an unknown type does to a whole list today, and how the UI treats the fallback variant.
Decide who owns discriminator values and how new variants are rolled out so old clients degrade gracefully instead of failing.
## Polymorphic payloads A **polymorphic payload** is a list whose elements have different shapes, told apart by a **discriminator** field. An order timeline is typical: ```json [ { "type": "placed", "id": 981, "at": "2026-09-01T10:15:00Z" }, { "type": "shipped", "id": 981, "carrier": "DHL" }, { "type": "refund_issued", "id": 981, "amount": 450 } ] ``` `freezed` is a code generator that writes immutable data classes and, together with `json_serializable`, their JSON mapping. A class with **several factory constructors** is a **union**: each constructor becomes a concrete subclass, and the generated `fromJson` picks one by reading the discriminator. ## Configuring the discriminator - **`unionKey`**: the JSON key to read. Default **`runtimeType`**, which almost no backend sends, so you normally set `@Freezed(unionKey: 'type')`. - **Values**: by default each variant's value is its **constructor name** (`placed`, `shipped`), and the unnamed constructor maps to `default`. - **`unionValueCase`**: a `FreezedUnionCase` (`none`, `kebab`, `pascal`, `snake`, `screamingSnake`) that renames all values at once. - **`@FreezedUnionValue('refund_issued')`** on one constructor overrides its value. - The same settings can be made project-wide in the generator's `build.yaml` options (`union_key`, `union_value_case`). The generated `toJson` writes the discriminator back, so a round trip through `toJson` and `fromJson` lands on the same variant. ## When the value is unknown The generated `fromJson` is a `switch` on `json[unionKey]` with one `case` per variant. Its `default` branch: | Configuration | Unknown `type` value | |---|---| | no `fallbackUnion` | throws `CheckedFromJsonException` ("Invalid union type") | | `fallbackUnion: 'unknown'` | builds the `unknown` constructor from the same JSON | | `fallbackUnion: 'default'` | builds the unnamed constructor | This matters in production because mobile apps are not updated in lockstep with the server. When the backend adds `"type": "returned"`, every installed version without a fallback throws while decoding the list, and a `List.map(fromJson)` fails as a whole, so the user sees an error screen instead of a timeline with one unfamiliar entry. A fallback variant, rendered generically or skipped, keeps old app versions working. ## Consuming the union Freezed 3.0 **removed the generated `when` and `map` methods**; the union is a `sealed` class and callers use Dart 3 pattern matching: 1. Declare the class `sealed` (since freezed 3, a class built from factory constructors must be declared `abstract` or `sealed`). 2. Name each variant's subclass on the right of `=` (`= OrderShipped`) so patterns can refer to it. 3. Write a `switch` expression over the subclasses; adding a variant then produces a compile error wherever it is not handled. That exhaustiveness check is a Dart language feature, not a freezed one. ## When the payload has no discriminator If the server cannot add a type key, the freezed README suggests a `JsonConverter<Base, Map<String, dynamic>>` that inspects the map (which keys are present) and calls the right variant's `fromJson`. It is more fragile, because two variants with overlapping keys become ambiguous, so it is the fallback, not the default. ## Testing the union Union decoding has more branches than a plain model, and each branch is a place for the client and server to disagree. Worth covering: - one recorded payload **per variant**, asserting the decoded subclass (`isA<OrderShipped>()`) and its fields; - a payload with an **unrecognised** `type`, asserting that it lands in the fallback variant rather than throwing; - a **round trip** through `toJson` and `fromJson` for each variant, which proves the discriminator value is written back exactly as it is read; - a **list** mixing known and unknown variants, which is the shape that breaks in production. ## Checklist - `part 'x.freezed.dart';` and `part 'x.g.dart';`, with `json_serializable` as a dev dependency. - `factory X.fromJson(Map<String, dynamic> json) => _$XFromJson(json);`, using `=>`, or freezed generates no `fromJson`. - Nested freezed objects in lists need `explicitToJson: true` for a plain-map `toJson`.
- Why is fallbackUnion a production concern rather than a nicety?Installed app versions lag the backend. Without a fallback, a new `type` value makes `fromJson` throw `CheckedFromJsonException`, and decoding a list with `map(fromJson)` fails entirely. A fallback variant lets old versions show or skip the unfamiliar entry while the rest of the timeline renders.
- How do you branch on the variants now that freezed no longer generates when and map?Freezed 3.0 removed them in favour of Dart 3 pattern matching. Declare the union `sealed`, name each subclass after `=`, and use a `switch` expression with object patterns such as `OrderShipped(:final carrier)`. The compiler reports any variant the switch misses.
- What if the backend sends no discriminator key at all?Write a `JsonConverter<OrderEvent, Map<String, dynamic>>` that inspects the map, for example checking which keys are present, and calls the matching subclass's `fromJson`; annotate the field that holds the union with it. It works for clearly distinct shapes but becomes ambiguous when variants share keys, so a discriminator is preferable.
saying these in an interview costs you the question
- freezed reads a key called type by default
- An unknown discriminator value decodes as null
- freezed 3 still generates when and map for unions
- fallbackUnion is only needed while the API is in beta
- The generated toJson omits the discriminator key