Converting a Messy API Response into a Clean Dart Model

The JSON you get from a real backend rarely looks like the clean example in the API documentation. Fields are nullable in practice even when the docs say they're required. Numbers arrive as strings sometimes and actual numbers other times, depending on which endpoint version served the response. Nested objects are inconsistently structured across list and detail endpoints. None of this is unusual. It's just what happens when a backend evolves over time and different endpoints were written by different people at different points in that evolution.
The mistake I see most often is writing the Dart model to match the documentation instead of the actual response. That model works until the first time a field that "should never be null" is null, and then you get a runtime crash instead of a compile-time warning, which is exactly the failure mode Dart's type system exists to prevent.
Start from a real response, not the documented shape
Before writing a model class, pull an actual response from the endpoint (not a sample from the docs) and look at it field by field. Specifically check: which fields are present in every response you've seen versus only some, which numeric fields come back as JSON numbers versus strings, and whether list items are structurally consistent with each other. This is tedious the first time and saves you from a category of bugs that only show up in production, on the one API response shape you didn't think to test.
Make optional-in-practice fields nullable, even if the docs say they're required
If a field has ever come back missing or null in a real response, the Dart field should be nullable, full stop. This feels like giving up some safety, but it's the opposite: a nullable field forces every place that reads it to handle the missing case explicitly, which is far safer than a non-nullable field that's actually populated with a null the type system doesn't know about (which, depending on how you parsed it, can produce a runtime type error instead of a clean compile-time nullability check).
class UserProfile {
final String id;
final String? displayName; // nullable because it's actually missing sometimes
final int followerCount;
UserProfile({
required this.id,
this.displayName,
required this.followerCount,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'] as String,
displayName: json['display_name'] as String?,
followerCount: (json['follower_count'] as num?)?.toInt() ?? 0,
);
}
}
Notice the as num? cast on followerCount rather than assuming int directly. If that field has ever arrived as a JSON string or a double from a different endpoint version, a direct as int cast throws immediately. Casting through num first and converting is a small amount of defensive code that absorbs a real inconsistency instead of crashing on it.
Where a generator genuinely saves time, and where it doesn't
A JSON-to-Dart generator is legitimately useful for the mechanical part of this: producing typed fields, a constructor, and a starting fromJson/toJson pair from a sample payload. That's boilerplate, and boilerplate is exactly what code generation is for. What a generator can't do is know which fields are actually nullable in production, because it can only infer types from the one sample you gave it. If a field is present and non-null in your sample, the generator has no way to know it's sometimes missing in other responses.
The practical workflow that actually holds up: generate the initial class from a real (not documented) sample to skip the typing boilerplate, then manually review every field against multiple real responses and loosen anything that isn't consistently present to nullable. Skipping that review step is how a generated model ends up just as brittle as one written by hand from the docs, just faster to produce.
For nested, non-serialization boilerplate specifically
If what you actually need is an immutable class with copyWith, equality, and toString, rather than JSON serialization itself, that's a narrower problem than the one above, and it's worth solving separately rather than bolting it onto every model by hand.
You can generate the initial typed class from a real API sample with the JSON to Dart converter, or generate just the immutable-class boilerplate (copyWith, equality, toString) with the Dart Data Class Generator if serialization isn't what you need. Either way, treat the output as a first draft to review against real API responses, not a finished model.
// tagged in
Related Articles
Skip the boilerplate
Production-ready Flutter starter kit with Firebase Auth, Firestore, Cloud Functions, push notifications, and Clean Architecture — ship your app in days, not months.
Did this article save you time?
I write these for free. If it helped, a coffee keeps me going — and more articles coming.