Skip to content
ExponaBLOG
FLUTTER · DAY 038 / 100

JSON serialization: what every Flutter developer should know

From API responses to typed models.

Expona Admin7 min read

The hidden complexity in API integration

You've just integrated a third-party CRM API into your Flutter app. The response lands in your inbox looking perfect on the surface, but when you run the code, a field is missing, a type doesn't match, or the nested object structure surprises you. JSON serialization: what every Flutter developer should know is the skill that separates "it works in development" from "it actually works in production." Whether you're handling lead data, payment responses, or user profiles, how you convert raw JSON into typed models determines whether your app gracefully handles edge cases or crashes at 3 a.m.

Manual fromJson — When to live with the overhead

Every Flutter developer starts here: parsing JSON by hand. You receive a map and manually construct your model, field by field. For a small prototype or a single endpoint returning a tiny payload, this approach is straightforward and gives you complete control.

dart
class Lead {
  final String id;
  final String name;
  final String? phone;

  Lead({
    required this.id,
    required this.name,
    this.phone,
  });

  factory Lead.fromJson(Map<String, dynamic> json) {
    return Lead(
      id: json['id'] as String,
      name: json['name'] as String,
      phone: json['phone'] as String?,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'phone': phone,
    };
  }
}

This works. You know exactly what's happening. But scale this to ten models, each with five to ten fields, and you're copying and pasting the same pattern. When the API adds a new field, you update the model and the serialization methods in three places. Typos creep in. Tests miss edge cases. For production apps handling CRM data, e-commerce catalogs, or trading systems, manual serialization becomes a maintenance burden that grows with your codebase.

Use manual fromJson when your model is genuinely simple and unlikely to change, or when you need fine-grained control over transformation logic. Otherwise, let code generation handle the boilerplate.

json_serializable — Eliminating boilerplate without overhead

The json_serializable package generates your fromJson and toJson methods automatically. You annotate your class and run the code generator. No more manual serialization, no more typos, and every field gets type-safe deserialization out of the box.

dart
import 'package:json_annotation/json_annotation.dart';

part 'lead.g.dart';

@JsonSerializable()
class Lead {
  final String id;
  final String name;
  final String? phone;

  Lead({
    required this.id,
    required this.name,
    this.phone,
  });

  factory Lead.fromJson(Map<String, dynamic> json) =>
      _$LeadFromJson(json);

  Map<String, dynamic> toJson() => _$LeadToJson(this);
}

After running flutter pub run build_runner build, the generator creates a lead.g.dart` file containing the fully typed serialization logic. If your API returns a missing field, the generator produces null-safe code that respects your nullability annotations. If you rename a field in the API, use @JsonKey(name: 'api_field_name') to map it.

dart
@JsonSerializable()
class Lead {
  final String id;
  final String name;
  @JsonKey(name: 'phone_number')
  final String? phone;

  Lead({
    required this.id,
    required this.name,
    this.phone,
  });

  factory Lead.fromJson(Map<String, dynamic> json) =>
      _$LeadFromJson(json);

  Map<String, dynamic> toJson() => _$LeadToJson(this);
}

For most production apps, json_serializable is the practical choice. It scales to hundreds of models without friction, and the generated code is optimized and predictable. The trade-off is a build step in your development cycle, but that's negligible in any real project.

freezed — Immutability, equality, and pattern matching

When you need more than just serialization, freezed provides immutable data classes with built-in copyWith, value equality, and pattern matching. On top of that, it integrates seamlessly with json_serializable. You get all the benefits of code generation plus structural guarantees.

dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'lead.freezed.dart';
part 'lead.g.dart';

@freezed
class Lead with _$Lead {
  const factory Lead({
    required String id,
    required String name,
    String? phone,
  }) = _Lead;

  factory Lead.fromJson(Map<String, dynamic> j) =>
      _$LeadFromJson(j);
}

With freezed, your Lead model is immutable by default. The constructor is private. You get a copyWith method for free, letting you create modified copies without mutating the original:

dart
final lead = Lead(id: '123', name: 'Alice');
final updated = lead.copyWith(phone: '555-1234');
// lead is unchanged; updated is a new instance

Equality is value-based, not reference-based. Two Lead instances with identical fields are considered equal, which is invaluable for testing and state management in BLoC or Riverpod:

dart
final lead1 = Lead(id: '123', name: 'Alice');
final lead2 = Lead(id: '123', name: 'Alice');
print(lead1 == lead2); // true

In real-world CRM or trading apps, immutability prevents accidental state mutations that cause subtle bugs. Your team can safely pass Lead instances between layers—BLoC, repository, UI—knowing no one can accidentally modify them mid-flight. For larger projects, this is a significant stability win.

Handling bad data — Defaults and validation for real-world APIs

The example code so far assumes the API behaves perfectly. In practice, fields arrive with unexpected types, missing entirely, or containing values outside your expected range. JSON serialization tooling can't read your business logic, so you need a strategy.

Missing fields: Use nullable types or provide defaults with @JsonKey:

dart
@freezed
class Lead with _$Lead {
  const factory Lead({
    required String id,
    required String name,
    @JsonKey(defaultValue: '') String phone,
    @JsonKey(defaultValue: 0) int priority,
  }) = _Lead;

  factory Lead.fromJson(Map<String, dynamic> j) =>
      _$LeadFromJson(j);
}

Type mismatches: If the API sometimes sends a string "123" and sometimes an integer 123, use a custom converter:

dart
class StringToIntConverter implements JsonConverter<int, dynamic> {
  const StringToIntConverter();

  @override
  int fromJson(dynamic json) {
    if (json is int) return json;
    if (json is String) return int.tryParse(json) ?? 0;
    return 0;
  }

  @override
  dynamic toJson(int object) => object;
}

@freezed
class Lead with _$Lead {
  const factory Lead({
    required String id,
    required String name,
    @StringToIntConverter() required int priority,
  }) = _Lead;

  factory Lead.fromJson(Map<String, dynamic> j) =>
      _$LeadFromJson(j);
}

Post-deserialization validation: After the model is built, validate business rules. Freezed models work well with factory constructors or assert statements, but for complex validation, consider a separate validation layer:

dart
@freezed
class Lead with _$Lead {
  const factory Lead({
    required String id,
    required String name,
    String? phone,
  }) = _Lead;

  factory Lead.fromJson(Map<String, dynamic> j) =>
      _$LeadFromJson(j);

  String? validate() {
    if (id.isEmpty) return 'ID cannot be empty';
    if (name.isEmpty) return 'Name cannot be empty';
    if (phone != null && phone!.length < 5) return 'Phone invalid';
    return null;
  }
}

In a repository or data layer, call validate() after deserialization and handle errors appropriately. This keeps your model simple while ensuring data integrity before it reaches your business logic.

Common mistakes

  • Forgetting the part directive: If you skip part 'model.g.dart';, the generated file won't be imported and your code won't compile. Always include the part directive at the top of your model file.
  • Not running the code generator: New files or significant changes won't reflect until you run flutter pub run build_runner build. In development, use flutter pub run build_runner watch to regenerate on every save.
  • Assuming API fields are always present: Even if documentation says a field is required, defensive coding with defaults or nullability saves crashes. Real APIs change, clients sometimes send incomplete payloads, and resilience is a feature.
  • Mixing manual and generated serialization: If you manually override fromJson but rely on the generated toJson, inconsistencies will bite you. Stick to one approach per model.
  • Ignoring nested objects: When your API response includes nested structures (e.g., a Lead with an Address), ensure both classes are properly serializable. Forgetting to annotate nested classes causes silent null values or deserialization exceptions.

Key takeaways

  • Manual fromJson gives control but doesn't scale; reserve it for genuinely simple, stable models.
  • json_serializable eliminates boilerplate and guarantees type safety; it's the default choice for production Flutter apps.
  • freezed adds immutability, copyWith, and value equality, making it ideal for state management and complex domains.
  • Always handle missing or malformed fields with defaults, validators, or custom converters; assume the API will disappoint you.
  • Run flutter pub run build_runner watch during development to keep generated files in sync with your model changes.
  • Test deserialization with real API responses, not just happy-path examples; edge cases hide in production data.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles