safe_json_serializable
Crash-safe fromJson for json_serializable — without forking it and
without per-field annotations.
Not affiliated with the official
json_serializable. This is an add-on that runs on top of it: it reuses json_serializable's generator and only replaces the casts that crash.
When a backend changes a field's type (123 becomes "123", an object becomes
[], a list becomes null), the code json_serializable generates casts with
as and throws, crashing your app. safe_json_serializable plugs tolerant
TypeHelpers into that generation, so every field — scalars, DateTime, nested
models, and lists — is parsed defensively. Your models stay plain
@JsonSerializable().
@JsonSerializable()
class User {
final int? id; // "123" -> 123 · "abc"/null -> null
final String name; // 123 -> "123" · missing -> throws (see below)
@JsonKey(defaultValue: 0) final int qty; // "abc"/missing -> 0
final DateTime? createdAt; // tolerant parse
final List<int?>? scores; // not-a-list -> null · ["1","x"] -> [1, null]
final Address? home; // non-object -> null (no crash)
final List<Address?>? tags; // non-list -> null · bad element -> null
}
Why not the alternatives?
| Approach | Crash-safe scalars | Nested / lists | No fork | No per-field code |
|---|---|---|---|---|
Forking json_serializable |
✅ | ✅ | ❌ (track upstream) | ✅ |
A JsonConverter add-on |
⚠️ nullable only | ⚠️ per-model converters | ✅ | ❌ |
safe_json_serializable |
✅ | ✅ | ✅ | ✅ |
TypeHelper is json_serializable's official hook for how each type generates
code. Because these helpers replace only the crashing casts and reuse everything
else, they apply everywhere a type appears — including inside lists and maps —
and you keep all of json_serializable's features (maps, enums, generics,
records).
Install
Two packages, mirroring json_annotation / json_serializable:
dependencies:
safe_json_annotation: ^0.2.1 # runtime helpers the generated code calls
dev_dependencies:
safe_json_serializable: ^0.2.1 # the build-time generator
build_runner: ^2.4.0
Then, in your package's build.yaml, disable the stock json_serializable
builder and enable the safe one (this is the one non-obvious step):
targets:
$default:
builders:
json_serializable:
enabled: false
safe_json_serializable:safe_json_serializable:
enabled: true
You don't need to declare
json_serializableyourself — this builder brings it, and the annotations (@JsonSerializable,@JsonKey) come fromjson_annotation, whichsafe_json_annotationre-exports.Always keep the
json_serializable: enabled: falseline. Ifjson_serializableis ever a direct dependency (yours or another package's), its builder would otherwise collide with this one. If it isn't, disabling it is a harmless no-op.
Options and generate_for
This builder honours all the usual json_serializable options
(explicit_to_json, include_if_null, field_rename, create_to_json, …) —
but put them under the safe_json_serializable builder, not under the
disabled json_serializable one:
targets:
$default:
builders:
json_serializable:
enabled: false
safe_json_serializable:safe_json_serializable:
enabled: true
options:
explicit_to_json: true
include_if_null: false
field_rename: snake
generate_for:
include:
- lib/**/models/**.dart
Options left on the disabled json_serializable builder are ignored.
Usage
Import safe_json_annotation in your models (it re-exports json_annotation,
so it's your only serialization import) and write ordinary json_serializable
classes:
import 'package:safe_json_annotation/safe_json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final int? id;
final String name;
final List<int?>? scores;
User({this.id, required this.name, this.scores});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
Generate as usual:
dart run build_runner build
Every @JsonSerializable() class in the package is now crash-safe.
Behaviour
The generator knows each field's declared type and nullability, so it picks the right behaviour per field automatically.
| Field | Bad input | Result |
|---|---|---|
int? / double? / String? |
"abc", wrong type |
null |
int / String (no default) |
"abc", missing |
throws FormatException |
int @JsonKey(defaultValue:) |
"abc", missing |
the default |
| any of the above | "123" for an int |
coerced (123) |
DateTime? |
bad string | null |
List<int?>? |
not a list | null |
List<int?>? |
["1","x",2] |
[1, null, 2] |
Model? |
not an object | null |
List<Model?>? |
not a list / bad elem | null / element null |
Nullable vs non-nullable (fail-fast)
- Nullable (
int?): a wrong/unparseable value becomesnull. Never throws. - Non-nullable, no default (
int): a coercible value is coerced ("123" -> 123), but a genuinely missing/unparseable value throws a clearFormatException. It does not silently invent0/''— a required field that isn't there is a real problem you want surfaced, not hidden. - Non-nullable with
@JsonKey(defaultValue: X): same coercion, falls back toXinstead of throwing.
So pick the policy per field with idiomatic json_serializable tools: make it
nullable to tolerate anything, or add defaultValue: to accept a fallback —
otherwise it fails loud.
Scalar coercion tolerates "123"↔123, 123→"123", "1"/"true"→true, epoch
millis / ISO strings for DateTime, and so on.
Limitations
- Generic models (
Foo<Bar>usinggenericArgumentFactories) are not specially handled — annotate those fields with@JsonKeyif needed. - Only
Listcollections are outer-guarded.Set/Iterableand the outer shape ofMapuse json_serializable's defaults (their scalar values are still parsed safely). explicit_to_jsonis off, sotoJson()leaves nested objects as objects untiljsonEncodeis applied — standard json_serializable behaviour.
How it works
json_serializable exposes JsonSerializableGenerator.withDefaultHelpers([...]).
This package prepends safe helpers for int, double, num, String, bool,
DateTime, List, and nested models, so they take precedence over the built-in
casts. The generated code calls the small tolerant parsers (safeInt,
safeString, …) shipped in the safe_json_annotation runtime package.
License
MIT — see LICENSE.