Stop Hand-Writing fromJson: @JsonKey Handles Every Mapping and Null Case
Flutter Gap-Filling: A Complete Guide to @JsonKey Attributes, So the Model Layer Stops Being Led by Backend Fields
Author: SoaringHeart
Keywords: Flutter, json_serializable, JsonKey, json_annotation, fromJson, toJson, build_runner, model layer
1. Origin of the Need
"When developing Flutter data models, a pain point arises: backend fields don't match Dart fields, hand-writing fromJson is long and stinky, and it trips on null and new enum values."
factory User.fromJson(Map<String, dynamic> json) {
return User(
userName: json['user_name'] as String, // backend uses snake_case
age: int.parse(json['age'].toString()), // backend gives a string
avatar: json['avatar'] as String?, // the whole key may be absent
);
}
Map<String, dynamic> toJson() => {
'user_name': userName,
'age': age.toString(),
'avatar': avatar, // should we send when null?
};
Three pain points:
- Field name mismatches force manual mapping — backend fields like
user_name,uid,create_timeare everywhere; the more fields, the more omissions. - Type mismatches force manual conversion — timestamps, numeric strings, nested objects, each model rewrites the same logic.
- Edge cases crash directly — missing keys, null values, new enum values from the backend, all become production incidents.
json_serializable can eliminate the repetitive work, but the generator by default only recognizes "field name == JSON key". The model in this project is typical:
// lib/models/living_info_entity.dart:14
@JsonSerializable()
class LivingInfoEntity {
late int prepareId; // JSON uses prepareId, works with zero config
late LivingInfoAnchor anchor;
factory LivingInfoEntity.fromJson(Map<String, dynamic> json) => $LivingInfoEntityFromJson(json);
Map<String, dynamic> toJson() => $LivingInfoEntityToJson(this);
}
When field names don't match, types don't match, or fault tolerance is needed, you must bring in @JsonKey.
The need is simple: move the dirty work of "field name mapping, value location, type conversion, and fallback defaults" from hand-written code to declarative annotations.
2. Usage Examples
2.1 name: Field Name Mapping (Most Common, Recommended)
@JsonSerializable()
class User {
@JsonKey(name: 'user_name')
final String? userName;
User({this.userName});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
JSON {"user_name": "Alex"} → Dart user.userName, the generated code is equivalent to:
userName: json['user_name'] as String?,
2.2 fromJson / toJson: Type Conversion
DateTime parseDate(Object? value) => DateTime.parse(value.toString());
String dateToJson(DateTime value) => value.toUtc().toIso8601String();
@JsonKey(fromJson: parseDate, toJson: dateToJson)
final DateTime createdAt;
2.3 defaultValue + includeIfNull: Defaults and Empty Values
@JsonKey(defaultValue: 0)
final int age; // JSON has no age → 0
@JsonKey(includeIfNull: false)
final String? avatar; // avatar is null → toJson omits this key
2.4 Comprehensive Example: One Model Covers All Common Scenarios
enum UserStatus { active, disabled, unknown }
Object? readUserId(Map map, String key) => map['user_id'] ?? map['userId'] ?? map['uid'];
@JsonSerializable()
class User {
/// Accept multiple backend field names
@JsonKey(readValue: readUserId)
final String id;
/// Different JSON field name
@JsonKey(name: 'user_name')
final String? userName;
/// JSON → Dart custom conversion
@JsonKey(fromJson: parseDate)
final DateTime createdAt;
/// Dart → JSON custom conversion
@JsonKey(toJson: dateToJson)
final DateTime updatedAt;
/// Do not send when null
@JsonKey(includeIfNull: false)
final String? avatar;
/// New backend enum values don't crash
@JsonKey(unknownEnumValue: UserStatus.unknown)
final UserStatus status;
/// Read-only, not sent back
@JsonKey(includeToJson: false)
final String? serverToken;
/// Pure client-side field, entirely excluded from serialization
@JsonKey(ignore: true)
final bool isSelected;
User({
required this.id,
this.userName,
required this.createdAt,
required this.updatedAt,
this.avatar,
required this.status,
this.serverToken,
this.isSelected = false,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
3. Source Explanation
Classified by responsibility, to avoid confusion later:
@JsonKey
├── Field mapping name, readValue
├── JSON → Dart fromJson, includeFromJson, required, defaultValue, disallowNullValue, unknownEnumValue
├── Dart → JSON toJson, includeToJson, includeIfNull
└── Field control ignore
3.1 name Changes Field Name, readValue Changes Value Location
@JsonKey(name: 'user_name')
final String? userName;
// Generated: userName: json['user_name'] as String?
readValue can simultaneously accept multiple backend field names to cover historical baggage:
static Object? readUserId(Map map, String key) {
return map['user_id'] ?? map['userId'] ?? map['uid'];
}
@JsonKey(name: 'user_id', readValue: readUserId)
final String id;
Easily overlooked detail: the second argument to readValue receives the value of name ('user_id'), not the Dart field name.
3.2 Division of Work Between readValue and fromJson
readValue decides where to get the value, fromJson decides how to transform it after getting it.
@JsonKey(readValue: readUserId, fromJson: parseInt)
final int userId;
{"uid": "123"} → readUserId() → "123" → parseInt() → 123
3.3 required and disallowNullValue Are Not the Same Thing
The boundaries are completely different:
| Config | Key missing | Key exists but value is null |
|---|---|---|
required: true |
throws | allowed |
disallowNullValue: true |
allowed | throws |
| Both true | throws | throws |
@JsonKey(required: true)
final String id; // The protocol requires id to exist
@JsonKey(disallowNullValue: true)
final String? name; // name may be absent, but if present it cannot be null
3.4 defaultValue Only Covers "Key Missing", Not Invalid Values
@JsonKey(defaultValue: 0)
final int age;
It only solves the {} case. Expecting it to turn "abc" into 0 is wishful thinking; a type mismatch still throws, and you need fromJson for that.
3.5 unknownEnumValue: Server Adds Enum Values Without Crashing the Client
This is the most valuable attribute for long-maintained App APIs:
enum UserStatus { active, disabled, unknown }
@JsonKey(unknownEnumValue: UserStatus.unknown)
final UserStatus status;
When the backend returns "deleted" and the client hasn't shipped a new version, the default behavior is a parse failure that takes down the whole API. With the fallback, it degrades to UserStatus.unknown.
Key trap: If you use unknownEnumValue, don't write your own fromJson for the enum; it's easy to overwrite the fallback.
3.6 ignore / includeFromJson / includeToJson: Switches in Three Directions
| Config | JSON → Dart | Dart → JSON |
|---|---|---|
| Default | ✅ | ✅ |
ignore: true |
❌ | ❌ |
includeFromJson: false |
❌ | ✅ |
includeToJson: false |
✅ | ❌ |
@JsonKey(ignore: true)
final String? localToken; // Ignored in both directions: local cache, UI state
@JsonKey(includeToJson: false)
final String? serverGenerated; // Read-only, not sent back: server-generated fields
Easily overlooked detail: ignore is bidirectional, includeFromJson / includeToJson are unidirectional. For "read-only" use includeToJson: false; writing ignore prevents even deserialization.
3.7 includeIfNull: Only Affects toJson
@JsonKey(includeIfNull: false)
final String? nickname;
When the value is null, it generates {} instead of {"nickname": null}. It only affects serialization output, not deserialization, and is best suited for request parameters.
4. Summary
What @JsonKey does is simple: it declaratively writes all rules for "how one Dart field corresponds to a JSON field" on the field itself.
Core value:
- name / readValue solve field mismatches: naming style differences, multiple backend field names, one annotation handles it.
- fromJson / toJson solve type mismatches: time, numeric strings, nested structures, conversion logic sits next to the field.
- required / disallowNullValue / defaultValue clarify null boundaries: a missing key and a null value are two different things, don't mix them.
- unknownEnumValue is a fuse for long-term maintenance: new backend enum values should not crash old client versions.
- ignore / includeFromJson / includeToJson control data flow: distinguishing "bidirectional ignore" from "unidirectional read-only" avoids half the serialization weirdness.
Judgment mantra: "Am I changing the field name, changing the value location, changing the type conversion, or changing whether the field participates in serialization?"
Annotations are not a silver bullet, but they transform the model layer from "chasing backend fields" to "describing the mapping by convention" — that's what a model layer should look like.