dart_easy_json
Code generation for JSON in Dart that doesn't fall over on bad data. Besides fromJson/toJson, it generates a fromJsonSafe that never throws and reports every problem with its exact path, a standalone validate, and declarative validation rules — all from annotations on plain Dart classes.
Quick start
1. Install
dart pub add dart_easy_json dev:build_runner
2. Annotate a model and import the file that will be generated next to it:
// lib/models/user.dart
import 'package:dart_easy_json/easy_json.dart';
import 'user.easy.dart';
@EasyJson()
class User with UserSerializer {
final String name;
final int age;
final String? email;
User({required this.name, required this.age, this.email});
factory User.fromJson(Map<String, dynamic> json) => userFromJson(json);
}
3. Generate the code
dart run build_runner build
4. Use it
final user = User.fromJson({'name': 'Ana', 'age': 30});
print(user.toJson()); // {name: Ana, age: 30}
Null fields are left out of toJson by default (see includeIfNull).
Why dart_easy_json?
- Parsing that never throws.
fromJsonSafebuilds the object even from broken data, using sensible fallbacks, and tells you what was wrong throughonIssue— so one bad field in an API response doesn't take down the whole screen. - Every problem, with its path. Issues come with the exact location (
items[2].price,shipping.address.city) and a stable, machine-readablecodeyou can switch on. - Validation from annotations.
@EasyValidate(minLength: 3),format: EasyFormat.email,min/max, dates in the past/future, or your own function — checked byvalidate,fromJsonSafeand strict mode. - Strict when you want it.
@EasyJson(strict: true)turnsfromJsoninto "validate everything, then throw one exception listing all the problems". - Covers the real-world cases: polymorphic unions, generic classes (
PageResponse<T>), nested paths, custom converters, inheritance, and types likeDateTime,Uri,Duration,BigIntandUint8Listout of the box.
Supported types
| Type | In JSON | Notes |
|---|---|---|
int, double, num, bool, String |
number / bool / string | Numeric strings ("5", "3.5") are accepted for int, double and num. |
| Enums | the value's name |
fromJsonSafe also accepts the index. |
DateTime |
ISO-8601 string | Also reads milliseconds since epoch. |
Uri |
string | |
Duration |
number of microseconds | Matches Duration.inMicroseconds, no precision lost. |
BigInt |
decimal string | JSON numbers can't safely carry big integers (a JS/web runtime only keeps exact integers up to 2^53). fromJsonSafe also accepts a plain number. |
Uint8List |
Base64 string | |
List<T>, Set<T> |
array | T can be any type in this table. |
Map<String, V>, Map<int, V> |
object | Keys are always strings in JSON; int keys are converted both ways. |
Other @EasyJson classes |
object | Nested models, including lists and maps of them. |
Type parameters (T) |
whatever your converter returns | See Generic classes. |
Anything else can be handled with @EasyConvert.
Safe parsing with fromJsonSafe
@EasyJson()
class Product with ProductSerializer {
@EasyValidate(minLength: 3)
final String name;
final double price;
final int stock;
Product({required this.name, required this.price, required this.stock});
factory Product.fromJsonSafe(
Map<String, dynamic> json, {
void Function(EasyIssue)? onIssue,
}) => productFromJsonSafe(json, onIssue: onIssue);
}
final issues = <EasyIssue>[];
final product = Product.fromJsonSafe(
{'name': 'TV', 'price': 'cheap'},
onIssue: issues.add,
);
// The object is still built, with fallbacks for what was wrong:
// product.name == 'TV', product.price == 0.0, product.stock == 0
for (final issue in issues) print(issue);
// [min_length] name: Must have at least 3 characters.
// [type_mismatch] price: Expected number.
// [missing_required] stock: Missing required field.
Each EasyIssue has a path, a code (see Issue codes) and a human-readable message, and each problem is reported once. Nullable fields stay null when the value is missing or null; non-nullable ones fall back to a neutral value (0, '', false, an empty list...), which you can customize with @EasyKey(fallback: ...).
Validating without parsing
Every model also gets a validate function (and a static validate on the generated ${Class}Json companion class) that only checks the payload, without building the object:
final problems = ProductJson.validate({'name': 'TV', 'price': 'cheap'});
if (problems.isNotEmpty) {
// reject the request, show the errors, ...
}
Validation rules with @EasyValidate
@EasyJson()
class Account with AccountSerializer {
@EasyValidate(minLength: 3, maxLength: 20, regex: r'^[a-z0-9_]+$')
final String username;
@EasyValidate(format: EasyFormat.email)
final String email;
@EasyValidate(min: 18, max: 120)
final int age;
@EasyValidate(past: true)
final DateTime birthDate;
@EasyValidate(custom: Rules.isEven)
final int luckyNumber;
// ... constructor
}
// Custom validators must be static methods or top-level functions.
class Rules {
static bool isEven(int value) => value.isEven;
}
| Rule | Applies to |
|---|---|
minLength, maxLength |
String (characters), List/Set/Map (elements) |
regex |
String |
format (EasyFormat.email, .url, .uuid) |
String |
min, max (inclusive) |
int, double, num |
past, future |
DateTime |
custom |
any type — a bool Function(FieldType value) |
Rules are checked by validate, by fromJsonSafe (as issues) and by fromJson in strict mode.
Strict mode
By default, the plain fromJson fails on bad data with whatever Dart's own casts throw — usually a TypeError that doesn't say which field was wrong. With strict: true, fromJson validates first and throws an EasyValidationException listing every problem:
@EasyJson(strict: true)
class SignUp with SignUpSerializer {
final String name;
final int age;
@EasyValidate(format: EasyFormat.email)
final String email;
SignUp({required this.name, required this.age, required this.email});
}
try {
signUpFromJson({'age': 'forty', 'email': 'ana@'});
} on EasyValidationException catch (e) {
print(e);
// EasyValidationException: 3 issue(s) found
// [missing_required] name: Missing required field.
// [type_mismatch] age: Expected int.
// [invalid_email] email: Invalid email.
}
Issues in nested objects and list items keep their full path (shipping.number, products[1].id). Strict mode also works on unions, where an unknown discriminator becomes an unknown_union_type issue. fromJsonSafe and validate behave the same with or without it.
Working with lists
Besides the per-object functions, each model gets helpers to convert a whole JSON array at once:
final users = userFromJsonList(jsonArray); // List<dynamic> -> List<User>
// Never throws; reports which item each issue came from.
userFromJsonSafeList(jsonArray, onIssue: (index, issue) {
print('item $index: $issue'); // item 1: [type_mismatch] age: Expected int.
});
final back = userToJsonList(users); // List<User> -> List<Map<String, dynamic>>
Generic classes
Classes with type parameters are supported. The generator can't know how to convert T, so the generated functions take one converter per type parameter — fromJsonT to read and toJsonT to write (the same convention as json_serializable):
@EasyJson()
class PageResponse<T> with PageResponseSerializer<T> {
final List<T> items;
final int total;
PageResponse({required this.items, required this.total});
}
final page = pageResponseFromJson(
json,
(item) => User.fromJson(item as Map<String, dynamic>),
);
final out = page.toJson((user) => user.toJson());
One PageResponse<T> serves every paginated endpoint, instead of a UserPage, ProductPage, ... per model. With several type parameters you pass one converter each, in declaration order (pairFromJson(json, fromJsonA, fromJsonB)); bounds like <T extends Base> are preserved; the companion class and list helpers take the same converters.
Current limitations — these fail at build time with a clear message instead of generating broken code:
- Generic fields can be
T,T?orList<T>.Set<T>,Map<K, T>and nested collections likeList<List<T>>aren't supported yet. - A generic
@EasyJsonclass can't be used as another field's type yet (e.g.final PageResponse<User> page;). - Generic
@EasyUnionclasses and fields inherited from a generic superclass aren't supported yet. - In
fromJsonSafe, a nullableT?whose converter throws is reported and becomesnull; a non-nullableThas no possible fallback, so it's only as safe as the converter you pass.
Unions
@EasyUnion handles polymorphic JSON: a discriminator field decides which subclass to build.
@EasyJson()
@EasyUnion(
discriminator: 'type',
mapping: {'text': TextPost, 'video': VideoPost},
fallback: UnknownPost,
)
sealed class Post {
Map<String, dynamic> toJson();
}
@EasyJson()
class TextPost extends Post with TextPostSerializer {
final String content;
TextPost({required this.content});
factory TextPost.fromJson(Map<String, dynamic> json) => textPostFromJson(json);
}
@EasyJson()
class VideoPost extends Post with VideoPostSerializer {
final String url;
VideoPost({required this.url});
factory VideoPost.fromJson(Map<String, dynamic> json) => videoPostFromJson(json);
}
@EasyJson()
class UnknownPost extends Post with UnknownPostSerializer {
UnknownPost();
factory UnknownPost.fromJson(Map<String, dynamic> json) => unknownPostFromJson(json);
}
final posts = [
{'type': 'text', 'content': 'Hello'},
{'type': 'video', 'url': 'https://youtu.be/x'},
{'type': 'poll'},
].map(postFromJson).toList();
// [TextPost, VideoPost, UnknownPost]
Each subclass needs a factory X.fromJson delegating to its generated function — the union's fromJson calls it. Without a fallback, an unknown type throws in fromJson (or is reported, in strict mode). A field typed List<Post> in another model is dispatched the same way.
Inheritance
Fields inherited from a superclass are serialized too, which fits architectures where a plain entity is extended by a JSON model:
class UserEntity {
final String emailAddress;
UserEntity({required this.emailAddress});
}
@EasyJson(caseStyle: CaseStyle.snake)
class UserModel extends UserEntity with UserModelSerializer {
UserModel({required super.emailAddress});
}
print(UserModel(emailAddress: 'a@b.com').toJson()); // {email_address: a@b.com}
To annotate an inherited field (a custom key, a validation rule, ...), @override it in the subclass and annotate it there.
Annotation reference
This model uses most of the field annotations at once:
class EpochMs {
static DateTime fromJson(Object? v) =>
DateTime.fromMillisecondsSinceEpoch(v as int, isUtc: true);
static int toJson(DateTime v) => v.millisecondsSinceEpoch;
}
@EasyJson(caseStyle: CaseStyle.snake)
class Order with OrderSerializer {
@EasyKey(name: '_id')
final String id;
final String customerName; // -> "customer_name"
@EasyPath('shipping.address.city')
final String city;
@EasyConvert(fromJson: EpochMs.fromJson, toJson: EpochMs.toJson)
final DateTime createdAt;
@EasyMapKey(type: EasyMapKeyType.int)
final Map<int, int> quantities;
@EasyIgnore()
final bool selected;
Order({
required this.id,
required this.customerName,
required this.city,
required this.createdAt,
required this.quantities,
this.selected = false,
});
}
final order = orderFromJson({
'_id': 'A-1',
'customer_name': 'Ana',
'shipping': {'address': {'city': 'Curitiba'}},
'created_at': 1700000000000,
'quantities': {'10': 2, '20': 1},
});
print(order.toJson());
// {_id: A-1, customer_name: Ana, created_at: 1700000000000,
// quantities: {10: 2, 20: 1}, shipping: {address: {city: Curitiba}}}
@EasyJson
| Parameter | Default | |
|---|---|---|
caseStyle |
null |
Key style for fields without an explicit name: CaseStyle.snake, .kebab, .camel, .pascal, .none. |
includeIfNull |
false |
Whether toJson writes null fields. |
strict |
false |
See Strict mode. |
fromJson / toJson |
true |
Set one to false to skip generating that direction (see below). |
@EasyKey
name: the JSON key for this field (overridescaseStyle).includeIfNull: overrides the class setting for this field.fallback: value used byfromJsonSafewhen the field is invalid — or missing/null, if the field isn't nullable. Supported forint,double,num,bool,Stringand, written as in JSON,DateTime(ISO string or epoch ms),Uri(string),Duration(microseconds),BigInt(int or string) andUint8List(Base64 string): e.g.@EasyKey(fallback: '2020-01-01T00:00:00Z').itemFallback: same, for each invalid item of aListor value of aMap.enumFallback: thenameof the enum value to use as fallback.
These values are checked at build time: a fallback of the wrong type, a malformed date or URI, an enumFallback that isn't a value of the enum, or a fallback on a type that doesn't support one fails the build with a message pointing at the field.
@EasyPath
Reads the field from a nested location ('shipping.address.city') without writing intermediate classes. toJson writes it back at the same nested location, so the output can be read again.
@EasyConvert
Your own conversion functions for the field (fromJson/toJson), or for each value of a Map (valueFromJson/valueToJson). They must be static methods or top-level functions.
@EasyMapKey
Converts JSON object keys (always strings) to int keys: {"10": 2} becomes Map<int, int>{10: 2}, and back to strings in toJson. Map<int, V> fields do this even without the annotation.
@EasyIgnore
Leaves the field out of both reading and writing. Since fromJson won't pass it, the constructor parameter must be optional (a default value, or nullable).
Read-only and write-only models
Skip the code you don't need for models that only go one way:
@EasyJson(toJson: false) // only fromJson / fromJsonSafe / validate
class LoginResponse {
final String token;
LoginResponse({required this.token});
}
@EasyJson(fromJson: false) // only toJson
class LoginRequest with LoginRequestSerializer {
final String user;
final String password;
LoginRequest({required this.user, required this.password});
}
Issue codes
The code of an EasyIssue is stable and safe to switch on.
| Code | Meaning |
|---|---|
missing_required |
A non-nullable field without a default value is missing. |
type_mismatch |
The value has the wrong type (e.g. a string where a number was expected). |
null_not_allowed |
A collection item is null but its type isn't nullable. |
invalid_enum |
A string that isn't the name of any enum value. |
invalid_enum_index |
An integer outside the enum's index range. |
key_type_mismatch |
A map key that can't be converted to the key type. |
invalid_uri |
A string that isn't a valid URI. |
invalid_bigint |
A string that isn't a valid integer. |
invalid_base64 |
A string that isn't valid Base64 (Uint8List). |
min_length, max_length |
@EasyValidate(minLength / maxLength) failed. |
regex_mismatch |
@EasyValidate(regex) failed. |
invalid_email, invalid_url, invalid_uuid |
@EasyValidate(format) failed. |
min_value, max_value |
@EasyValidate(min / max) failed. |
must_be_past, must_be_future |
@EasyValidate(past / future) failed. |
custom_validation_failed |
@EasyValidate(custom) returned false. |
unknown_union_type |
The discriminator doesn't match any class in @EasyUnion's mapping. |
Configuration
Putting generated files in a separate folder
By default, user.easy.dart is created next to user.dart. To keep generated files in their own folder, add a build.yaml to your project:
targets:
$default:
builders:
dart_easy_json:easy_json_builder:
options:
build_extensions:
"^lib/{{}}.dart": "lib/generated/{{}}.easy.dart"
Then import them from there: import 'package:my_app/generated/models/user.easy.dart';.
Analyzer
The generated files only import public libraries, so no analyzer configuration is needed. If you prefer to keep them out of your lint reports anyway:
# analysis_options.yaml
analyzer:
exclude:
- "**.easy.dart"
API stability
dart_easy_json follows semantic versioning since 1.0.0. These are part of the public API — breaking any of them requires a major version:
- The annotations and their parameters:
@EasyJson,@EasyKey,@EasyValidate,@EasyUnion,@EasyConvert,@EasyMapKey,@EasyIgnore,@EasyPath,CaseStyle,EasyFormat. - The names of generated code:
${x}FromJson,${x}ToJson,${x}Validate,${x}FromJsonSafe,${x}FromJsonList,${x}FromJsonSafeList,${x}ToJsonList, the${Class}Serializermixin, the${Class}Jsoncompanion class and, for generic classes, thefromJson${T}/toJson${T}converter parameters. - The shape of
EasyIssue(path/code/message), the issue codes andEasyValidationException. - The public exports of
package:dart_easy_json/easy_json.dartandpackage:dart_easy_json/runtime.dart.
@EasyConvert's functions are intentionally untyped (Function?) rather than generic, trading compile-time checking of the converter for flexibility. Anything under lib/src/ is internal and may change in any release.
Changelog
See CHANGELOG.md. Upgrading from 1.0.x? The 1.1.0 entry has an "Upgrading from 1.0.x" section listing every behavior change and what to check.