Convert HTTP Responses into Strongly-Typed Data and Failures
Deconstruct how to parse API responses safely, handle missing vs null fields, preserve large numeric IDs, and model HTTP failures without catch-all exceptions.
1. The Core Challenge & Problem
Mobile applications frequently communicate with backend microservices undergoing active development. In production, backend updates introduce subtle payload shifts: a previously required field is omitted, an ID exceeds 53 bits (causing IEEE-754 precision loss in web clients), or an explicit null replaces an empty object.
When Flutter apps handle these responses with naive catch-all try { ... } catch (e) blocks and direct json['field'] as String casts, two severe failures occur: 1. Masked Errors: A 401 Unauthorized, a 500 Server Crash, and a local offline timeout all collapse into a generic "Something went wrong" message, preventing retry policies or re-authentication flows. 2. Runtime Deserialization Crashes: If an unexpected payload arrives, Flutter throws type 'Null' is not a subtype of type 'String' or FormatException, crashing the screen or showing an unhelpful error screen.
2. Architectural Principles & Resolution
Building reliable Flutter networking requires separating transport errors from domain deserialization and understanding the exact semantics of JSON differences:
### 1. The Four Rules of Lossless Response Inspection When comparing a stable baseline API response with a changed incoming response (as supported by WidgetExamples' API Inspector tool): - Observed Differences β Server Contract Proof: An absent key in an observed response is merely an observed difference in that single payload sample. It does not prove the field is optional or nullable in the backend OpenAPI contract. - Missing vs Explicit Null: In RFC 8259 JSON, omitting a key ({ }) and setting a key to null ({ "avatar": null }) are distinct operations. In Dart deserialization, map.containsKey('avatar') distinguishes presence from nullity. - 64-bit Integer Precision: Standard JavaScript JSON.parse coerces numbers to 64-bit floating-point doubles, losing precision for values above 2^53 - 1 (e.g. Snowflake IDs like 9007199254740993). In Dart and lossless tools, store large IDs as lexemes (String) or BigInt. - Index-Based Array Alignment: JSON arrays are ordered sequences. Unless a unique identity field is designated, array modifications are inspected by index order.
### 2. Transport vs Domain Failure Boundaries Never let raw http.Response or untyped Map<String, dynamic> leak into your presentation widgets. Wrap responses in an explicit Result<T, ApiFailure> union so the UI compiler forces exhaustive handling of network, authorization, server, and parsing failures.
3. Complete Flutter Code
main.dart (Flutter 3.x+ ready)import 'dart:convert';
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ApiReliabilityDemo()));
/// 1. Strongly-typed failure hierarchy
sealed class ApiFailure {
final String message;
const ApiFailure(this.message);
}
class NetworkFailure extends ApiFailure {
const NetworkFailure(super.message);
}
class AuthFailure extends ApiFailure {
const AuthFailure(super.message);
}
class ServerFailure extends ApiFailure {
final int statusCode;
const ServerFailure(this.statusCode, super.message);
}
class SerializationFailure extends ApiFailure {
final String path;
const SerializationFailure(this.path, super.message);
}
/// 2. Functional Result type
sealed class Result<T, E> {
const Result();
R when<R>({
required R Function(T data) success,
required R Function(E failure) failure,
});
}
class Success<T, E> extends Result<T, E> {
final T data;
const Success(this.data);
@override
R when<R>({required R Function(T) success, required R Function(E) failure}) =>
success(data);
}
class Failure<T, E> extends Result<T, E> {
final E error;
const Failure(this.error);
@override
R when<R>({required R Function(T) success, required R Function(E) failure}) =>
failure(error);
}
/// 3. Defensive DTO handling 64-bit IDs and missing vs null fields
class UserDto {
final String userId; // Preserved as string for 64-bit precision
final String username;
final String displayName;
final String? avatarUrl; // Nullable
final List<String> roles;
final bool isVerified;
const UserDto({
required this.userId,
required this.username,
required this.displayName,
this.avatarUrl,
required this.roles,
required this.isVerified,
});
factory UserDto.fromJson(Map<String, dynamic> json) {
// Validate required root object
final data = json['data'] is Map<String, dynamic>
? json['data'] as Map<String, dynamic>
: json;
// Preserving 64-bit integer ID without IEEE-754 loss
final rawId = data['userId'];
if (rawId == null) {
throw const FormatException('Missing required field: userId');
}
final profile = data['profile'] as Map<String, dynamic>? ?? {};
return UserDto(
userId: rawId.toString(),
username: data['username'] as String? ?? 'unknown',
displayName: profile['displayName'] as String? ?? 'Anonymous',
avatarUrl: profile['avatarUrl'] as String?, // Safe nullable cast
roles: (profile['roles'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
const [],
isVerified: profile['verified'] as bool? ?? false,
);
}
}
/// 4. Resilient parser with failure domain mapping
Result<UserDto, ApiFailure> parseUserResponse(int statusCode, String body) {
if (statusCode == 401) {
return const Failure(AuthFailure('Session expired. Please log in again.'));
}
if (statusCode >= 500) {
return Failure(ServerFailure(statusCode, 'Backend server error.'));
}
if (statusCode != 200) {
return Failure(ServerFailure(statusCode, 'Unexpected HTTP status code.'));
}
try {
final decoded = jsonDecode(body);
if (decoded is! Map<String, dynamic>) {
return const Failure(SerializationFailure('root', 'Expected JSON object root'));
}
final user = UserDto.fromJson(decoded);
return Success(user);
} on FormatException catch (e) {
return Failure(SerializationFailure('format', e.message));
} catch (e) {
return Failure(SerializationFailure('unknown', e.toString()));
}
}
/// 5. Demo presentation UI
class ApiReliabilityDemo extends StatefulWidget {
const ApiReliabilityDemo({super.key});
@override
State<ApiReliabilityDemo> createState() => _ApiReliabilityDemoState();
}
class _ApiReliabilityDemoState extends State<ApiReliabilityDemo> {
String _activePayload = 'v1';
late Result<UserDto, ApiFailure> _result;
// Baseline v1 payload
static const String payloadV1 = '''{
"status": "success",
"data": {
"userId": 9007199254740993,
"username": "chirag_shyani",
"profile": {
"displayName": "Chirag Shyani",
"avatarUrl": null,
"roles": ["engineer", "mentor"]
}
}
}''';
// Changed v2 payload (adds verified boolean, adds lead role, updates avatarUrl)
static const String payloadV2 = '''{
"status": "success",
"data": {
"userId": 9007199254740994,
"username": "chirag_shyani",
"profile": {
"displayName": "Chirag Shyani",
"avatarUrl": "https://assets.widgetexamples.com/avatars/chirag.png",
"roles": ["engineer", "mentor", "lead"],
"verified": true
}
}
}''';
@override
void initState() {
super.initState();
_result = parseUserResponse(200, payloadV1);
}
void _switchPayload(String version) {
setState(() {
_activePayload = version;
_result = parseUserResponse(200, version == 'v1' ? payloadV1 : payloadV2);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('API Response Parsing')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ElevatedButton(
onPressed: () => _switchPayload('v1'),
child: const Text('Load Stable v1'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => _switchPayload('v2'),
child: const Text('Load Changed v2'),
),
],
),
const SizedBox(height: 16),
_result.when(
success: (user) => Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('User: ${user.displayName} (@${user.username})',
style: Theme.of(context).textTheme.titleMedium),
Text('ID (Lossless 64-bit): ${user.userId}'),
Text('Avatar: ${user.avatarUrl ?? "None (Null)"}'),
Text('Roles: ${user.roles.join(", ")}'),
Text('Verified: ${user.isVerified ? "Yes" : "No"}'),
],
),
),
),
failure: (failure) => Card(
color: Colors.red.shade100,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Error: ${failure.message}',
style: const TextStyle(color: Colors.red)),
),
),
),
],
),
),
);
}
}
4. Expected Visual & Behavioral Result
Both stable v1 and changed v2 responses parse deterministically without casting exceptions. 64-bit IDs retain numeric precision, nullable fields are safely checked, and missing keys fall back cleanly.
5. Common Pitfalls & Traps
Pitfall #1: Using direct forced casts like `json["avatar"] as String` when fields can be null.
json["avatar"] as String? or null-coalescing operators ??.Pitfall #2: Parsing 64-bit integer IDs as native JavaScript numbers in Web builds, causing truncation.
data["userId"].toString()) or parse with BigInt.tryParse.Pitfall #3: Assuming that because a field is omitted in a sample payload, it is optional in the server contract.
Pitfall #4: Catching all exceptions with catch (e) and showing a generic error screen.