Flutter: Debugging JSON Parsing Errors
1. "App Crashed: _TypeError (Null is not a subtype of String)"
The server dev said, "I updated the API." Then my app died with the Red Screen of Death.
type 'Null' is not a subtype of type 'String' in type cast
Every Flutter dev has seen this in their nightmares. The culprit is 99% a JSON Parsing issue involving Null.
"You said String is required!"
Ideally, yes. But defensively, I was wrong not to clean the data. If the server sends null, my app shouldn't commit suicide.
2. The Principle: Dart's Strict Types
Since Dart 2.12 Null Safety, variables are non-nullable by default.
String name; // Can NEVER be null
String? bio; // Can be null
JSON is dynamic runtime data (Map<String, dynamic>).
The compiler doesn't know if json['name'] is String or null.
When you cast it as String or pass it to a required constructor, it explodes at runtime if the value turns out to be null.
3. Solution 1: Use Defaults or Nullable (Safest)
Don't trust API docs 100%. "Required" fields can be null due to server bugs or DB migrations.
The Client must be Crash-free against garbage data.
// ❌ Dangerous: Explicit casting assumes safety
class User {
final String name; // Crashes if null
factory User.fromJson(Map<String, dynamic> json) {
return User(name: json['name']);
}
}
// ✅ Safe: Implicit safety
class User {
final String name;
factory User.fromJson(Map<String, dynamic> json) {
// Fallback to default if null
return User(name: json['name'] ?? 'Unknown');
}
}
The ?? operator (Null-aware operator) is your savior.
If json['name'] is null, it uses 'Unknown'. The app lives on.
4. Solution 2: Filter Bad Apples with try-catch
If loading a list of 100 items, and 1 has malformed data, don't fail the whole list. Filter out the bad ones using a try-catch block inside the mapping function.
List<User> parseUsers(List<dynamic> jsonList) {
return jsonList.map((json) {
try {
return User.fromJson(json);
} catch (e) {
print('Bad data ignored: $json');
return null; // Return null on failure
}
}).whereType<User>() // Remove nulls from the stream
.toList();
}
Now the app survives even if the server sends one user with name: null while others are fine.
5. Deep Dive: Use freezed
Hand-coding fromJson is prone to typos.
Use freezed + json_serializable.
The @Default annotation makes handling nulls trivial and readable.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
factory User({
@Default('Unknown') String name, // Auto-fills if null
@Default(0) int age, // Default to 0 if null
@Default([]) List<String> tags, // Default to empty list
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
This generates defensive code automatically. You will rarely see a parsing error again.
6. Deep Dive: The BigInt Problem
Be careful with 64-bit Integers (e.g., Database IDs, Twitter Snowflakes). Dart (when compiled to Web/JS) and JavaScript only support safe integers up to $2^-1$ (approx 9 quadrillion). If the ID is larger, the last digits will get corrupted (rounding error).
Best Practice: Ask the backend to send IDs as Strings. If you must handle numbers, implement a Custom JsonConverter to handle the type casting safely.
class StringOrIntConverter implements JsonConverter<String, dynamic> {
const StringOrIntConverter();
@override
String fromJson(dynamic json) {
// Handle both String and Int from server
return json.toString();
}
@override
dynamic toJson(String object) => object;
}
This flexibility protects your app from silent data corruption when the backend decides to change the ID type without notice.
7. Pro Tip: Zod (For TypeScript Users)
If you are using React/TypeScript, types like interface User vanish at runtime. They don't protect you from bad JSON.
Use Zod for "Runtime Validation".
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string().default("Guest"), // Fallback
age: z.number().optional(), // Nullable
});
// Safe Parse
const result = UserSchema.safeParse(response.data);
if (!result.success) {
// Handle error gracefully instead of crashing
console.error(result.error);
} else {
// result.data is typed AND validated
console.log(result.data.name);
}
Zod ensures that the data entering your application boundaries matches your expectations before it hits your UI components. It acts as a gatekeeper.
8. Case Study: The $10,000 Mistake (When Integers Became Floats)
I learned this the hard way.
I was building an e-commerce app. The backend promised to send price as an Int.
So I coded final int price = json['price'];.
On Black Friday, the server team deployed a "10% Discount Feature".
Suddenly, prices became floats (49.99 instead of 50).
The exact JSON payload was {"price": 49.99}.
My app, expecting an Int, crashed on the "Product List Page".
Users couldn't see any products. Sales dropped to zero for 2 hours until I hotfixed it.
Lesson: For money, always use num (covers both int and double) or String.
Never assume types are immutable. The server can (and will) change them.
9. Glossary
- Deserialization: Converting a JSON String -> Dart Object.
- Null Safety: A Dart feature that enforces variables to be non-nullable unless explicitly marked with
?. - JsonConverter: A helper class in
json_serializablethat intercepts the parsing logic to transform data (e.g., String -> Date). - BigInt: An integer type that can hold numbers larger than $2^$.
10. FAQ: Common Parsing Issues
Q: type '_InternalLinkedHashMap<String, dynamic>' is not a subtype of type 'List<dynamic>'
A: You are expecting a List [], but the server sent a Map {}. Check if the response is wrapped in a data field (e.g., { "data": [...] }).
Q: How to handle generic types Result<T>?
A: freezed supports Generics. You can define factory Result.fromJson(Map<String, Object?> json, T Function(Object?) fromJsonT). It requires passing a deserializer function for T.
Q: Is dynamic dangerous?
A: Yes. dynamic disables the type checker. Avoid using it unless absolutely necessary. Prefer Object? if the type is unknown, forcing you to check types before usage.
11. Summary
- Don't trust the Server. Nulls happen. Types change.
- Defensive Coding: Use
??to provide defaults. - Partial Failure: Filter bad items (
whereType) instead of crashing the whole list. - Automation: Use
freezed(Dart) orZod(TS) to automate validation. - IDs as Strings: Avoid BigInt precision issues.
A slightly empty UI (missing name) is infinitely better than a Crashed App.