json_factory_runtime_generator
Generates mixins that turn JSON back into the right json_serializable class at runtime, using a key stored in the JSON itself. You write no mapping by hand.
| Package | Add as | Contains |
|---|---|---|
json_factory_runtime_annotation |
dependencies |
@jsonFactoryKey(...), @JsonFactoryKey(...) |
json_factory_runtime_generator |
dev_dependencies |
build_runner builders (auto-applied) |
The annotation has to be a normal dependency because your lib/ code imports it. The generator is dev-only:
dart pub add json_factory_runtime_annotation json_annotation
dart pub add dev:json_factory_runtime_generator dev:json_serializable dev:build_runner
Choosing where to put the key
A class gets its key in one of two ways. Both decode the same way, and both can be used in the same package.
// On a final field: toJson() writes the key.
@JsonSerializable()
class User {
@jsonFactoryKey()
final String factoryKey = 'user';
...
}
// On the class: toJson() stays plain, and toJsonForFactory adds the key.
@JsonFactoryKey('user')
@JsonSerializable()
class User {
...
}
Use a final field by default. All of the class's JSON then carries the key, so it can always be decoded through the factory, and you can't forget to add it.
Use the class annotation when toJson() has to stay plain, for example when the same class is also sent to an API that rejects unknown fields. It also suits a class with a hand-written toJson() and no @JsonSerializable, or a key held in a named constant.
Final field @jsonFactoryKey() |
Class @JsonFactoryKey('key') |
|
|---|---|---|
toJson() |
Writes the key | Doesn't write the key |
| Getting JSON with the key | toJson() or toJsonForFactory(object) |
toJsonForFactory(object) only |
| Nested objects | Keyed automatically, including nullable fields and lists | Plain, unless a non-nullable field adds @JsonKey(toJson: Mapper.toJsonForFactory) |
| Class gains a field | Yes | No |
| Key value | String literal, or enum constant with a string @JsonValue |
Any constant string |
@JsonSerializable |
Required | Optional |
Subclass that inherits toJson() |
Must annotate its own key field | Needs its own @JsonFactoryKey if @JsonSerializable |
A class can't use both forms, and a class with @JsonFactoryKey can't extend a class with a @jsonFactoryKey field. The build stops in either case. See Rules.
Usage
Add a final String field holding the class's key, and annotate it with @jsonFactoryKey(). Parentheses are required, since it is a constructor call:
@JsonSerializable()
class User {
...
@jsonFactoryKey() // default mixin: JsonFactoryMixin
final String factoryKey = 'user';
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
@JsonSerializable()
class Order {
...
@jsonFactoryKey(mixin: 'ApiMixin') // named mixin
final String factoryKey = 'order';
}
jsonFactoryKey extends JsonKey, so json_serializable writes the key into toJson() and fromJson ignores it:
{"id": "1", "name": "Ada", "__type__": "user"}
The key is written for nested objects and lists as well.
Using an enum for the key
The field can also be an enum. The key is the constant's @JsonValue, which is what json_serializable writes into toJson():
enum Kind {
@JsonValue('user')
user,
}
@JsonSerializable()
class User {
...
@jsonFactoryKey()
final Kind factoryKey = Kind.user;
}
The @JsonValue has to be a non-empty string. A constant without one stops the build.
Renaming a key
To rename a key without breaking JSON that was already stored or sent with the old one, list the previous keys in oldKeys. It is optional; leave it out when a class has never been renamed.
@jsonFactoryKey(oldKeys: ['user', 'person'])
final String factoryKey = 'member';
toJson() now writes "__type__": "member", and the generated mixin still decodes "user" and "person" to the same class.
Annotating the class instead
To keep the key out of the class's fields and its toJson(), put @JsonFactoryKey on the class. The key can be any constant string, and name, mixin and oldKeys work the same way:
@JsonFactoryKey('user')
@JsonSerializable()
class User {
...
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
toJson() doesn't write the key, so use the generated toJsonForFactory for JSON that will be decoded through the factory:
user.toJson(); // {"id": "1", "name": "Ada"}
JsonFactoryMapper.toJsonForFactory(user); // {"id": "1", "name": "Ada", "__type__": "user"}
JSON without the key can't be decoded through the factory, and the failure only shows up at runtime. That applies to a direct toJson() call and to nested objects, because a parent's generated toJson() calls the child's. To include the key for a nested field, add:
@JsonKey(toJson: JsonFactoryMapper.toJsonForFactory)
final User customer;
The parameter of toJsonForFactory is non-nullable, so this form doesn't suit a nullable field. If toJson() itself needs to include the key, for example for nested lists, use @jsonFactoryKey() on a field instead. Both forms can be used in the same package.
dart run build_runner build
This writes lib/generated/json_factory_runtime.g.dart with, for each mixin name, the mixin and a static mapper class. For the models above, that is JsonFactoryMixin with JsonFactoryMapper (holding User), and ApiMixin with ApiMapper (holding Order).
The mapper's name is the mixin's with a trailing Mixin replaced by Mapper (ApiMixin → ApiMapper). Other names get Mapper appended (Api → ApiMapper). The build stops if a mapper name equals another mixin's name, or if two mixins end up with the same mapper.
Using the generated code
Call the mapper class directly:
import 'generated/json_factory_runtime.g.dart';
final user = JsonFactoryMapper.fromKeyedJsonAs<User>(json);
final order = ApiMapper.fromKeyedJsonAs<Order>(json);
For code that takes the mixin as an object, JsonFactoryMapper.instance is a const JsonFactoryMixin.
To get the methods on a class of your own, such as a repository or an API client, mix the mixin in:
class UserRepository with JsonFactoryMixin {
Future<Object> load(String id) async => fromKeyedJson(await _fetch(id));
Future<Map<String, dynamic>> _fetch(String id) async => ...;
}
Methods
All of these are static on the mapper class and instance members of the mixin, except keyField, which is static on both.
| Member | Returns | Use when |
|---|---|---|
toJsonForFactory(object) |
Map<String, dynamic> |
Writing object.toJson() with its key added. Throws ArgumentError if the exact type of object isn't registered. |
fromKeyedJson(json) |
Object |
The key is in the JSON (json['__type__']). |
fromKeyedJsonAs<T>(json) |
T |
Same, and you know the type you expect. |
fromJsonByKey(key, json) |
Object |
The key comes from somewhere else, such as a URL or a header. |
fromJsonAs<T>(key, json) |
T |
Same, typed. |
hasMapperKey(key) |
bool |
Checking a key before decoding. Old keys count. |
mapperKeys |
Iterable<String> |
Listing the current keys in the mixin. Old keys aren't listed. |
keyField |
String |
Reading the JSON field name, for example JsonFactoryMapper.keyField. |
Classes that mix in the mixin don't inherit keyField, so read it through the mapper or the mixin (JsonFactoryMapper.keyField, JsonFactoryMixin.keyField), not through your class (UserRepository.keyField doesn't compile).
Decoding a value whose type isn't known in advance
final json = jsonDecode(body) as Map<String, dynamic>;
// {"id": "1", "name": "Ada", "__type__": "user"}
switch (JsonFactoryMapper.fromKeyedJson(json)) {
case User user:
print('user ${user.name}');
case Address address:
print('address in ${address.city}');
case final other:
print('unhandled ${other.runtimeType}');
}
Decoding a list of mixed types
Each item carries its own key, so one call per item rebuilds the right class:
final items = (jsonDecode(body) as List)
.cast<Map<String, dynamic>>()
.map(JsonFactoryMapper.fromKeyedJson)
.toList();
// [User(...), Address(...), User(...)]
Round trip
With a @jsonFactoryKey field, toJson() writes the key, so its output goes straight back through the factory:
final json = const User(id: '1', name: 'Ada').toJson();
final copy = JsonFactoryMapper.fromKeyedJsonAs<User>(json);
The same applies to stored data, such as a cache or a local database, as long as it was written with toJson(). With @JsonFactoryKey on the class, write the JSON with toJsonForFactory instead.
Key passed separately
When the JSON has no key field, for example because it comes from an endpoint that always returns one type, pass the key yourself:
final user = JsonFactoryMapper.fromJsonAs<User>('user', json);
Errors
Both of these throw an ArgumentError:
fromKeyedJsonwhen the JSON has no string key field.fromJsonByKeyandfromKeyedJsonwhen no class in that mixin is registered under the key.
Use hasMapperKey to check first when the input isn't trusted:
final key = json[JsonFactoryMapper.keyField];
if (key is String && JsonFactoryMapper.hasMapperKey(key)) {
return JsonFactoryMapper.fromJsonByKey(key, json);
}
return null;
fromKeyedJsonAs<T> and fromJsonAs<T> also throw a TypeError if the decoded object isn't a T.
Several mixins
Each mixin only knows its own classes. The same key can mean different classes in different mixins:
JsonFactoryMapper.fromJsonByKey('user', json); // User
ApiMapper.fromJsonByKey('user', json); // ApiUser
ApiMapper.hasMapperKey('address'); // false
Configuration (build.yaml next to your pubspec.yaml)
targets:
$default:
builders:
json_factory_runtime_generator:json_factory_aggregator:
options:
output: lib/src/factories/json_factory_runtime.g.dart # default: lib/generated/json_factory_runtime.g.dart
key_field: kind # default: __type__
-
outputmust be a relative.dartpath underlib/. All mixins go into this one file. -
key_fieldis the JSON fieldfromKeyedJsonreads. The fieldjson_serializablewrites comes from the annotation, so every@jsonFactoryKeymust use the samename, or the build stops. Defining the annotation once in your own code makes that easy:const typeKey = jsonFactoryKey(name: 'kind'); const apiTypeKey = jsonFactoryKey(name: 'kind', mixin: 'ApiMixin'); @typeKey final String factoryKey = 'user';
Rules
- A key must be unique within a mixin. If two classes share one, the build stops and the error names both classes. The same key can be used in different mixins.
- Old keys count as keys: within a mixin, each old key must differ from every class's current key and every other old key, or the build stops and the error marks which side is the old key. An old key also can't be empty, repeat in the list, or equal its own class's current key.
- A class can use
@JsonFactoryKeyor a@jsonFactoryKeyfield, not both.@JsonFactoryKeyneeds a non-empty key and atoJson()on the class or a superclass, but not@JsonSerializable. A concrete@JsonSerializablesubclass of a class with@JsonFactoryKeyneeds its own@JsonFactoryKey, or the build stops. @JsonFactoryKeygoes on the class. On a method, getter or field it stops the build. To havetoJson()write the key, use a@jsonFactoryKey()field instead.- A class with
@JsonFactoryKeycan't extend a class with a@jsonFactoryKeyfield, or the build stops, because itstoJson()would write the parent's key. Annotate its own key field instead. The opposite is allowed: a class with a@jsonFactoryKeyfield can extend a class with@JsonFactoryKey. - A
@jsonFactoryKeymember has to be a final, non-lateinstance field, either aStringinitialized with a string literal (final String factoryKey = 'user';) or an enum initialized with a constant that has a string@JsonValue(final Kind factoryKey = Kind.user;). Getters, static fields and values set by a constructor stop the build. Each class can have only one. - A concrete
@JsonSerializablesubclass of a registered class must annotate its own key field (with@overrideif it has the parent's name), or the build stops. Otherwise its JSON wouldn't decode back to the subclass. Give it its ownfromJsontoo, unless the parent'sfromJsonpicks the subclass itself. The override triggers theoverridden_fieldslint if you have it enabled; add// ignore: overridden_fieldson the line directly abovefinal String. - A class with a
@jsonFactoryKeyfield must be annotated with@JsonSerializable; otherwise the build stops, because the key would never be written to its JSON. - The class has to be public, non-generic and under
lib/. It also needs afromJson(Map<String, dynamic>)factory or static method. - Every generated mixin has the same member names, so a class should mix in only one of them.
Example
A complete project with models, a custom build.yaml and tests is in example/.
Libraries
- json_factory_runtime_generator
- build_runner entry points for json_factory_runtime_generator.