跪拜 Guibai
← Back to the summary

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:

  1. Field name mismatches force manual mapping — backend fields like user_name, uid, create_time are everywhere; the more fields, the more omissions.
  2. Type mismatches force manual conversion — timestamps, numeric strings, nested objects, each model rewrites the same logic.
  3. 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:

  1. name / readValue solve field mismatches: naming style differences, multiple backend field names, one annotation handles it.
  2. fromJson / toJson solve type mismatches: time, numeric strings, nested structures, conversion logic sits next to the field.
  3. required / disallowNullValue / defaultValue clarify null boundaries: a missing key and a null value are two different things, don't mix them.
  4. unknownEnumValue is a fuse for long-term maintenance: new backend enum values should not crash old client versions.
  5. 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.