← All posts

6 min read

JSON Serialization in Flutter with Freezed and json_serializable

JSON serialization in Flutter: manual fromJson vs json_serializable vs freezed, copyWith, sealed unions, @JsonKey, build_runner and messy API data.

  • Flutter
  • JSON
  • Code Generation
Cover illustration for JSON Serialization in Flutter with Freezed and json_serializable

Every Flutter app that talks to a server eventually has a class called User with a fromJson factory that someone wrote by hand at 11 p.m. It works until the backend renames a field, sends null where it never did before, or adds a new status value. Then the app crashes on launch for everyone, all at once.

JSON parsing is boring, which is exactly why it deserves a boring, reliable setup. Here are the questions I get most often about it, and how I answer them on my own projects.

Should I write fromJson by hand, or generate it?

Both are legitimate. It depends on how many models you have.

By hand is fine for a small app with a few models. No dependencies, no build step, and you can see exactly what happens:

class Product {
  const Product({required this.id, required this.name, required this.price});

  factory Product.fromJson(Map<String, dynamic> json) => Product(
        id: json['id'] as String,
        name: json['name'] as String,
        price: (json['price'] as num).toDouble(),
      );

  final String id;
  final String name;
  final double price;
}

The as num then toDouble() is a classic trap, by the way. JSON has no separate integer and double types, so a price of 10 arrives as an int, and json['price'] as double throws.

json_serializable generates the same fromJson and toJson code from annotations. Once you have more than a handful of models, it’s worth it. The generated code handles nesting, lists, dates and type conversions consistently, and you stop making typos in key names.

freezed sits on top of json_serializable and also generates equality, hashCode, toString, copyWith and union types. If your models are immutable data classes, which in a state-managed Flutter app they usually should be, freezed removes a lot of hand-written code.

My default for anything beyond a prototype is freezed plus json_serializable.

What does a freezed model look like today?

The examples below use freezed 3.x. The main change from older tutorials is that the class itself needs a modifier, abstract for a plain data class or sealed for a union, alongside with _$ClassName. If you copy a snippet from an older blog post and get a confusing error, that’s usually why.

First the dependencies:

dependencies:
  freezed_annotation: ^3.0.0
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^3.0.0
  json_serializable: ^6.8.0

Then a model:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
abstract class User with _$User {
  const factory User({
    required String id,
    required String email,
    @JsonKey(name: 'display_name') String? displayName,
    @Default(false) bool isVerified,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

That gives you a User with value equality, toJson(), and copyWith:

final updated = user.copyWith(isVerified: true);

copyWith is the feature people end up using most. State updates in Riverpod or BLoC become one readable line instead of a constructor call listing every field.

How do I run build_runner without it fighting me?

The generated .freezed.dart and .g.dart files come from build_runner:

dart run build_runner build --delete-conflicting-outputs

While you’re actively editing models, use watch instead. It regenerates whenever you save:

dart run build_runner watch --delete-conflicting-outputs

The --delete-conflicting-outputs flag tells build_runner to overwrite generated files it didn’t create in this run, for example after switching branches. Without it, you sometimes get an error asking you to delete them yourself. Recent build_runner versions have been changing how this is handled, so if your version warns that the flag is unnecessary, you can drop it.

A few habits that save time:

  • Decide whether generated files are committed and stick with it. I commit them on client projects. CI builds and code reviews are simpler, and nobody gets a broken checkout because they forgot to run the generator. Some teams prefer to gitignore them and generate in CI. Both work. Mixing them doesn’t.
  • Keep models in their own files. build_runner is faster and errors are easier to read when each file has one job.
  • If generation gets weird, clean up. dart run build_runner clean followed by a fresh build fixes most stale-cache problems.

Freezed unions or Dart 3 sealed classes?

Before Dart 3, freezed unions were the best way to model “one of several shapes” in Dart. Now the language has sealed classes and exhaustive switch, which I use constantly (more on that in Dart 3 features I use every day).

Freezed still supports unions, and in 3.x they’re plain sealed classes underneath, so you pattern match them with a normal switch:

@freezed
sealed class PaymentEvent with _$PaymentEvent {
  const factory PaymentEvent.succeeded({required String paymentId}) =
      PaymentSucceeded;
  const factory PaymentEvent.failed({required String reason}) = PaymentFailed;

  factory PaymentEvent.fromJson(Map<String, dynamic> json) =>
      _$PaymentEventFromJson(json);
}

For JSON unions, freezed reads a runtimeType key by default to decide which case to build. You can change the key with @Freezed(unionKey: 'type') so it matches whatever discriminator your API sends.

My rule: if the union comes from JSON, or the cases need copyWith and equality, I use freezed. For app-side state with a few small cases, like a screen’s loading, loaded and failed states, I write a hand-made sealed class. It’s a few more lines, but there’s no code generation and anyone who knows Dart can read it. My Riverpod vs BLoC post shows that style.

How do I deal with APIs that rename things?

@JsonKey handles most mismatches between API names and Dart names:

@JsonKey(name: 'created_at') required DateTime createdAt,

If the whole API uses snake_case, don’t annotate every field. Set it once in a build.yaml at the project root:

targets:
  $default:
    builders:
      json_serializable:
        options:
          field_rename: snake

Now createdAt maps to created_at automatically, and you only annotate the odd ones out. Dates are parsed from ISO 8601 strings automatically. If your API sends Unix timestamps instead, you’ll need a small JsonConverter.

What happens when the server sends an enum value I don’t know?

This is the one that causes launch-day crashes. Your app knows pending, paid and refunded. The backend adds disputed. Every old version of the app now fails to parse any order with that status.

Give the enum a fallback:

enum OrderStatus {
  @JsonValue('pending')
  pending,
  @JsonValue('paid')
  paid,
  @JsonValue('refunded')
  refunded,
  unknown,
}

And tell json_serializable to use it:

@JsonKey(unknownEnumValue: OrderStatus.unknown)
required OrderStatus status,

The UI then shows something neutral for unknown, like the raw status text or a generic label, instead of crashing. Since you can’t force users to update, assume the server will always be ahead of some installed version of your app. The same thinking applies to feature flags and forced updates.

Should API fields be required or nullable?

My rule is to model what the API actually guarantees, not what you hope it guarantees.

  • required and non-nullable for fields the backend contract promises: IDs, creation timestamps, anything the database itself requires. If one is missing, parsing should fail loudly, because something is genuinely wrong.
  • Nullable for anything optional or recently added: profile photos, middle names, a field that only exists for some account types.
  • @Default(...) when “missing” has a clear meaning, like an empty list of tags or false for a flag.

Be especially careful with fields added after launch. Older records in the database often don’t have them, so they need to be nullable or have a default, even if new records always include them.

When parsing does fail, catch the error at the API boundary, report it, and show the user a normal error state. A CheckedFromJsonException with the field name in your crash reports beats a mystery null error three screens later.

Takeaways

  • Hand-written fromJson is fine for a few models. Beyond that, generate it.
  • Freezed adds immutability, equality and copyWith on top of json_serializable. In freezed 3.x, mark classes abstract or sealed with with _$Name.
  • Run dart run build_runner watch while editing models, and decide as a team whether generated files are committed.
  • Use freezed unions for JSON-backed variants, and plain Dart 3 sealed classes for simple app state.
  • Always give enums an unknown fallback with unknownEnumValue.
  • Make fields required only when the API guarantees them, and give newer fields a default or make them nullable.

Get the models right once, and the next backend change becomes a code review comment instead of an outage.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).