skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. one factory constructor per variant
  2. default key is runtimeType
  3. unionKey, unionValueCase, @FreezedUnionValue
  4. unknown value: CheckedFromJsonException
  5. fallbackUnion names a catch-all

basics

~10 s

Declare 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 s

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

for a junior

Recall that a freezed union has one factory constructor per variant and that fromJson picks the variant from a key in the JSON.

for a middle

Explain unionKey and its runtimeType default, how unionValueCase and FreezedUnionValue set the values, and how toJson writes the key back.

for a senior

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.

for a principal

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