With json_serializable, how do you decode a generic response envelope such as Page<T>, and what does genericArgumentFactories generate?
answer
- the generator cannot build a T
- genericArgumentFactories: true
- T Function(Object? json) fromJsonT
- Object? Function(T value) toJsonT
- parents pass the lambdas for you
basics
~10 sSet @JsonSerializable(genericArgumentFactories: true) on Page<T>; the generated fromJson then takes a T Function(Object? json) fromJsonT and toJson takes an Object? Function(T value) toJsonT, which the caller supplies, for example Order.fromJson.
solid answer
~30 sA generic class is a problem for a generator: when it writes `Page<T>.fromJson`, it has no idea how to turn the `items` elements into a `T`. `@JsonSerializable(genericArgumentFactories: true)` solves that by adding one helper parameter per type parameter: `_$PageFromJson<T>(json, T Function(Object? json) fromJsonT)` and `_$PageToJson<T>(page, Object? Function(T value) toJsonT)`. My factory forwards it, and the call site says what `T` is: `Page<Order>.fromJson(map, (o) => Order.fromJson(o as Map<String, dynamic>))`. Lists and sets of `T` are mapped with the same function. When a non-generic model has a `Page<Order>` field, the generator writes those lambdas itself. The option has no effect on non-generic classes.
code
dart · 10 linesFuture<Page<Order>> fetchOrders(Dio dio, {String? cursor}) async {
final response = await dio.get<Map<String, dynamic>>(
'/orders',
queryParameters: {'cursor': ?cursor},
);
return Page<Order>.fromJson(
response.data!,
(item) => Order.fromJson(item as Map<String, dynamic>),
);
}go deeper
Recall that a generic envelope needs genericArgumentFactories and that the caller passes a function turning each item into T.
Describe the exact helper signatures, how lists of T use them, and how a parent model gets the lambdas generated.
Justify passing factories over shape-inspecting converters, and show where in the repository layer the concrete type is decided.
Consider whether one generic envelope across all endpoints is a contract worth enforcing with the backend team, and how it affects evolving pagination.
## Why generic envelopes need help Many APIs wrap every list response in the same envelope, `{ "items": [...], "next_cursor": "...", "total": 240 }`, and it is natural to model that once as `Page<T>`. With a concrete type, `json_serializable` generates `Order.fromJson(e as Map<String, dynamic>)` for each element. With a **type parameter** it cannot: Dart has no way to call a constructor on `T` at runtime, and Flutter has no reflection to discover one. Something outside the class has to say how a `T` is built. ## What the option generates With **`genericArgumentFactories: true`** (default `false`), the generator adds a helper parameter for each type parameter: - `fromJson` gains **`T Function(Object? json) fromJsonT`**. The generated code calls it for a field of type `T`, and maps it over the elements of a `List<T>` or `Set<T>`. - `toJson` gains **`Object? Function(T value) toJsonT`**, used the same way in reverse. - With two type parameters, `T` and `S`, you get `fromJsonT` and `fromJsonS`, and so on. Your own members must declare and forward those parameters, since the generated functions are private to the part file: ```dart @JsonSerializable(genericArgumentFactories: true, fieldRename: FieldRename.snake) class Page<T> { Page({required this.items, this.nextCursor, required this.total}); final List<T> items; final String? nextCursor; final int total; factory Page.fromJson( Map<String, dynamic> json, T Function(Object? json) fromJsonT, ) => _$PageFromJson(json, fromJsonT); Map<String, dynamic> toJson(Object? Function(T value) toJsonT) => _$PageToJson(this, toJsonT); } ``` ## Calling it 1. **At the API call**, supply the element factory: `Page<Order>.fromJson(body, (o) => Order.fromJson(o as Map<String, dynamic>))`. The cast is needed because the helper receives `Object?`. 2. **Inside another model**, you usually do nothing: when a non-generic class has a field of type `Page<Order>`, the generator writes the lambdas for you, and for primitive `T` it writes conversions such as `(value) => (value as num).toInt()`. 3. **In toJson**, pass the reverse function: `page.toJson((order) => order.toJson())`. ## Alternatives and their costs | Approach | How `T` is built | Weakness | |---|---|---| | `genericArgumentFactories` | caller passes `fromJsonT` | one extra argument at each top-level call | | A `JsonConverter<T, Object?>` on the field | the converter inspects the JSON's keys to guess the type | brittle; breaks when two types share keys | | One concrete class per endpoint (`OrderPage`) | ordinary generated code | duplication across many envelopes | | `@JsonKey(fromJson:)` with a static generic function | type tests inside the function | same guessing problem as a converter | The package's own examples show the shape-inspecting approach and flag its assumption in a comment; it works for a closed set of payloads and fails quietly otherwise. Passing the factory is explicit and type-safe, which is why it is the usual choice for paging and result envelopes. ## Testing and evolving the envelope A generic envelope is shared by many endpoints, so it deserves its own tests: 1. Decode a recorded page with `Page<Order>` and with a primitive type such as `Page<int>`, which exercises both a model helper and a numeric cast. 2. Round-trip it through `toJson((o) => o.toJson())` and back. 3. Decode an **empty page** (`items: []`, `next_cursor: null`), the case most screens get wrong. When the backend adds a field to the envelope, such as a `has_more` flag, it is added once to `Page<T>` and every endpoint gains it. That single point of change is the main argument for the generic class over per-endpoint copies. ## Details that trip people up - The option does **nothing on a class without type parameters**; set on one class it echoes a warning, while set project-wide in `build.yaml` it silently applies only to generic classes. - `freezed` supports the same flag, `@Freezed(genericArgumentFactories: true)`, and its `fromJson` factory must declare the extra parameter the same way. - A nullable element type (`Page<Order?>`) needs a helper that handles `null`, because `fromJsonT` receives the raw `Object?`. - Networking clients hand back an untyped body (dio's `response.data` is `dynamic`), so a small repository method that does the cast and supplies `fromJsonT` keeps the generic plumbing out of the UI.
- What happens if you set genericArgumentFactories on a class that has no type parameters?Nothing is generated differently. Set on that class's annotation, the generator echoes a warning in the build log. Set for the whole package in `build.yaml`, it is applied only to classes that do have type parameters, so no warning appears.
- Why does the fromJsonT helper take Object? rather than Map<String, dynamic>?Because `T` might be a primitive, a list or a map: `Page<int>` receives numbers, `Page<Order>` receives maps. The generator cannot assume a shape, so it passes the raw decoded value and leaves the cast to the helper you supply.
saying these in an interview costs you the question
- json_serializable can call T.fromJson on a type parameter by itself
- genericArgumentFactories changes code for every class, generic or not
- Inspecting keys in a converter is as safe as passing a factory
- A model with a Page<Order> field must pass the helper lambdas by hand
- The fromJsonT helper receives an already-typed Map<String, dynamic>