Skip to content

Generic JsonConverter<T> is not applied to List<T> fields #1589

Description

@PrashantRanpura

Description

A generic JsonConverter targeting List<T>? is not picked up by json_serializable, even when the generic type parameter is explicitly specified on the annotation.

Instead, json_serializable generates the default List<dynamic> cast and completely ignores the converter.

This causes a runtime TypeError when the API returns a single JSON object instead of an array.

Reproduction

Environment

  • Flutter version: 3.44.9
  • Dart version: 3.12.2
  • json_serializable: 6.14.1
  • json_annotation: 4.12.0
  • build_runner: 2.15.1
  • OS: Win 11

Generic converter

import 'package:json_annotation/json_annotation.dart';

class SafeListConverter<T> implements JsonConverter<List<T>?, Object?> {
  
  final T Function(Map<String, dynamic> json) fromJsonFactory;
  final Object? Function(T value) toJsonFactory;

  const SafeListConverter(this.fromJsonFactory, this.toJsonFactory);

  @override
  List<T>? fromJson(Object? json) {
    print('CONVERTER CALLED: ${json.runtimeType}');

    if (json is Map<String, dynamic>) {
      return [fromJsonFactory(json)];
    }

    if (json is List) {
      return json
          .whereType<Map<String, dynamic>>()
          .map(fromJsonFactory)
          .toList();
    }

    return null;
  }

  @override
  Object? toJson(List<T>? object) {
    if (object == null) return null;
    if (object.isEmpty) return '';

    if (object.length == 1) {
      return toJsonFactory(object.first);
    }

    return object.map(toJsonFactory).toList();
  }
}

Model

import 'package:json_annotation/json_annotation.dart';

part 'model.g.dart';

@JsonSerializable()
class Response {
  @SafeListConverter<Product>(Product.fromJson, _productToJson)
  final List<Product>? products;

  const Response({this.products});

  factory Response.fromJson(Map<String, dynamic> json) => _$ResponseFromJson(json);

  Map<String, dynamic> toJson() => _$ResponseToJson(this);
}

@JsonSerializable()
class Product {
  final int id;
  final String name;

  const Product({required this.id, required this.name});

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);

  Map<String, dynamic> toJson() => _$ProductToJson(this);
}

Object? _productToJson(Product product) {
  return product.toJson();
}

Generated code

I expected the converter to be applied to the products field.

Instead, json_serializable generates:

Response _$ResponseFromJson(Map<String, dynamic> json) => Response(
  products: (json['products'] as List<dynamic>?)
      ?.map((e) => Product.fromJson(e as Map<String, dynamic>))
      .toList(),
);

The SafeListConverter<Product> is completely omitted.

The same generated output was observed with json_serializable 6.13.0.

Runtime failure

The API I'm integrating with has an inconsistent response format.

For one item it returns:

{
  "products": {
    "id": 1,
    "name": "iPhone"
  }
}

instead of:

{
  "products": [
    {
      "id": 1,
      "name": "iPhone"
    }
  ]
}

Because the generated code performs:

json['products'] as List<dynamic>?

the following exception occurs:

_TypeError:
type '_Map<String, dynamic>' is not a subtype of type 'List<dynamic>?'
in type cast

The important point is that SafeListConverter.fromJson() is never called.

Control test

I tested the same scenario with a non-generic converter:

class SafeProductListConverter implements JsonConverter<List<Product>?, Object?> {
  const SafeProductListConverter();

  @override
  List<Product>? fromJson(Object? json) {
    print('>>> CONVERTER CALLED: ${json.runtimeType}');

    if (json is Map<String, dynamic>) {
      return [Product.fromJson(json)];
    }

    if (json is List) {
      return json
          .whereType<Map<String, dynamic>>()
          .map(Product.fromJson)
          .toList();
    }

    return null;
  }

  @override
  Object? toJson(List<Product>? object) {
    if (object == null) return null;
    if (object.isEmpty) return '';

    if (object.length == 1) {
      return object.first.toJson();
    }

    return object.map((e) => e.toJson()).toList();
  }
}

Used as:

@SafeProductListConverter()
final List<Product>? products;

With this converter, the generated code correctly invokes the converter and the runtime tests pass.

The output confirms:

>>> CONVERTER CALLED: _Map<String, Object>
>>> CONVERTER CALLED: List<Map<String, Object>>
>>> CONVERTER CALLED: Null
>>> CONVERTER CALLED: String

Therefore, the issue appears to be specifically related to the generic converter:

JsonConverter<List<T>?, Object?>

rather than JsonConverter support in general.

Expected behavior

The generator should evaluate the assigned constructor metadata, resolve the generic substitution (T -> Product), realize that JsonConverter<List<Product>?, Object?> matches the field type signature List<Product>?, and invoke the converter directly:

Given:

@SafeListConverter<Product>(
  Product.fromJson,
  _productToJson,
)
final List<Product>? products;

I would expect generated code to invoke the converter, approximately:

products: const SafeListConverter<Product>(
  Product.fromJson,
  _productToJson,
).fromJson(json['products']),

so that the converter can handle both:

{
  "products": {
    "id": 1,
    "name": "iPhone"
  }
}

and:

{
  "products": [
    {
      "id": 1,
      "name": "iPhone"
    }
  ]
}

If this pattern is not supported, it would be helpful if the documentation could clarify the limitation or recommend the supported pattern for reusable generic list converters.

If it is expected to work, this appears to be a type-matching issue in code generation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions