json_annotation_lenient
Lenient JSON conversion for json_serializable — coerce loose int/double/num/bool/String/DateTime values instead of throwing, plus a drop-in build_runner builder that auto-fills type-based defaults for missing fields.
English · 简体中文
Why json_annotation_lenient?
Real-world APIs rarely send perfectly-typed JSON — an int field shows up as
"42", a bool field shows up as 1, a timestamp shows up as a string one
day and an epoch number the next. Stock json_serializable throws on all of
these. json_annotation_lenient gives you two independent tools to deal with it:
- Converters — six
JsonConverterimplementations that coerce common alternate shapes forint/double/num/bool/String/DateTime, each falling back to a sensible default when the value is missing ornull. - A build_runner builder —
autoDefaultJsonBuilder, a full replacement for the stockjson_serializablebuilder. It auto-fills type-based defaults for any non-nullable field with no explicit@JsonKey(defaultValue:), and — for a class marked@LenientConverter(...)— rewrites its matching fields to route through the converters above automatically, so you don't have to stack a@LenientIntConverter()/@LenientDoubleConverter()/ ... annotation on every single field.
Both pieces work independently: use just the converters with the stock
builder, or pull in the builder for its auto-defaults even on classes that
never use @LenientConverter.
Installation
dependencies:
json_annotation_lenient: ^1.1.0
json_annotation_lenient is designed to be a regular dependency (not
dev_dependencies) even in a project that only uses it at build time — a
build.yaml builder plugin has to be resolvable from the depending project's
regular dependency graph.
Using the converters
import 'package:json_annotation/json_annotation.dart';
import 'package:json_annotation_lenient/json_annotation_lenient.dart';
part 'foo.g.dart';
@JsonSerializable()
class Foo {
@LenientIntConverter()
final int count; // accepts 42, 42.0, or "42"
@LenientBoolConverter()
final bool active; // accepts true, 1, "1", or "true"
@LenientDateTimeConverter()
final DateTime createdAt; // accepts ISO 8601, "2024/01/01 10:00:00", or an epoch number
Foo(this.count, this.active, this.createdAt);
}
Each converter falls back to a default value when the JSON value is missing
or null — 0 for int/num, 0.0 for double, false for bool, '' for
string, and the Unix epoch for DateTime (override via the generated
@JsonKey(defaultValue: ...) where the type allows a const default —
DateTime doesn't, since it has no const constructor).
@LenientConverter — enable leniency for a whole class
Rather than stacking one converter annotation per field, mark the class
itself and every field of a matching scalar type gets routed through the
corresponding converter automatically (this requires the
autoDefaultJsonBuilder, see below):
@LenientConverter() // int/double/num/bool/String/DateTime all enabled
@JsonSerializable()
class Foo {
final int count; // routed through LenientIntConverter
final bool active; // routed through LenientBoolConverter
@LenientConverter(double: false)
final double score; // untouched — opted out just for this field
@DisableLenient()
final int strictId; // untouched — opts out of everything
Foo(this.count, this.active, this.score, this.strictId);
}
@LenientConverter(dateTimeUtc: true) (class- or field-level) controls how a
timezone-less DateTime value is interpreted — see the dartdoc on
LenientDateTimeConverter for the full rules.
Using the builder
Add a build.yaml at your project root that disables the stock
json_serializable builder and enables json_annotation_lenient's replacement instead:
targets:
$default:
builders:
json_serializable:
enabled: false
json_annotation_lenient:auto_default:
enabled: true
options:
explicit_to_json: true
The auto_default builder itself is already declared by this package's own
build.yaml (with auto_apply: none) — don't redeclare a builders: block
in your own build.yaml, it would register a second builder writing the same
.g.dart output and build_runner will refuse to run with a
"conflicting outputs" error.
Then run build_runner as usual:
dart run build_runner build --delete-conflicting-outputs
With this builder active:
- Every non-nullable field with no explicit
@JsonKey(defaultValue:)falls back to a type-based default ('',0,0.0,false,const [],const {}) instead of throwing on a missing/null value. - A class annotated
@LenientConverter(...)gets its matching-type fields routed through the correspondingLenient*Converter— no per-field annotation needed. - A field marked
@DisableLenient()is left completely untouched by both of the above (pure stockjson_serializablebehavior).
options under json_annotation_lenient:auto_default are passed straight through to
the underlying JsonSerializableGenerator (explicit_to_json,
field_rename, ... — the same options the stock json_serializable builder
accepts) — except for the lenient: section below, which the builder consumes
itself.
explicit_to_json defaults to true when you don't set it at all —
nested @JsonSerializable fields need their own .toJson() called
explicitly to serialize correctly, and the stock json_serializable default
of false meant every consumer had to opt in by hand. Write
explicit_to_json: false yourself if you rely on the old implicit-toJson
behavior — an explicit value in either direction is always honored.
options.lenient — project-wide leniency, no annotations needed
@LenientConverter is per-class. If you want the same policy across the whole
project, configure it once in build.yaml instead:
targets:
$default:
builders:
json_annotation_lenient:auto_default:
enabled: true
options:
explicit_to_json: true
lenient:
int:
enabled: true
defaultValue: 0
double:
enabled: true
defaultValue: 0.0
num:
enabled: false
bool:
enabled: true
defaultValue: false
string:
enabled: true
defaultValue: ""
dateTime:
enabled: false
utc: false
The six type keys map to the six converters (int, double, num, bool,
string, dateTime). Every key — including enabled, defaultValue and
utc — is optional: only what you actually write is applied, and everything
else keeps the annotation-driven behavior. Classes with no
@LenientConverter/@DisableLenient annotation at all are covered too, so a
yml-only setup works — as long as the model file itself still imports
package:json_annotation_lenient/json_annotation_lenient.dart. The rewrite
emits a bare LenientIntConverter()/etc. reference into the generated part
file, which has no imports of its own and inherits the library's; without
that import the generated code fails with an undefined-name error (the
builder also logs a build-time warning when this happens).
Precedence for enabled, highest first:
- The field's own
@LenientConverter(...)— the yml can never override it. - The yml's
enabled:for that type — overrides the class-level annotation. - The class-level
@LenientConverter(...).
@DisableLenient() on a field still trumps all three (the field is left
exactly as stock json_serializable generated it).
Precedence for the fallback value, highest first:
- The field's
@JsonKey(defaultValue: ...). - The yml's
defaultValue:for that type. - The built-in type default (
'',0,0.0,false).
A yml defaultValue: applies to plain auto-defaulted fields too, not just
lenient ones — with string.defaultValue: "n/a" a non-lenient String field
generates json['x'] as String? ?? 'n/a'.
dateTime specifics: it accepts enabled: and utc: (the latter
overrides a class-level @LenientConverter(dateTimeUtc:), but not a
field-level one), and does not support defaultValue: — DateTime has no
const constructor, so there's no Dart literal to emit. A defaultValue:
written under dateTime is ignored rather than treated as an error.
The whole section is parsed leniently: a malformed entry (wrong shape, wrong value type) is skipped rather than failing the build.
Known limitations
The auto-default rewrite only recognizes the specific code shapes
json_serializable emits for bool/num/int/double/String/DateTime
scalars, List<T>/Set<T>, and Map<K, V>. A few field shapes are
deliberately left untouched — they behave exactly like stock
json_serializable (i.e. still throw on a missing/null value), not silently
broken:
- Nested
@JsonSerializableobject fields (final Bar bar;generatingBar.fromJson(json['bar'] as Map<String, dynamic>)) get no auto-default. Give the field its own@JsonKey(defaultValue: ...), or make it nullable, if you need one. @JsonKey(fromJson: ..., toJson: ...)custom function fields and any third-party@JsonConverternot from this package pass straight through unmodified — the rewrite never touches a shape it doesn't explicitly recognize, precisely so it can't emit code that fails to compile.enumfields are not given an invented fallback value — there's no generically "correct" default enum member the way0/''/falseare for scalars. Use@JsonKey(defaultValue: MyEnum.foo)(works out of the box with stockjson_serializable, no leniency needed) for a missing/null key, and/or@JsonKey(unknownEnumValue: MyEnum.foo)for a value that isn't one of the enum's cases.
Contributing
Issues and PRs are welcome.
- 🐛 Open an issue
- 🔧 Send a PR — please run
dart analyzeanddart test(fromjson_annotation_lenient/) before submitting.
License
MIT — see LICENSE.