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
2. Architectural Principles & Resolution
3. Complete Tested 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
5. Common Pitfalls & Traps
Pitfall #1: Using direct forced casts like `json["avatar"] as String` when fields can be null.
Remedy: Use safe nullable casts `json["avatar"] as String?` or null-coalescing operators `??`.
Pitfall #2: Parsing 64-bit integer IDs as native JavaScript numbers in Web builds, causing truncation.
Remedy: Extract numeric IDs as String lexemes (`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.
Remedy: Treat absent fields as observed differences in that specific payload. Verify against your backend OpenAPI/Swagger schema.
Pitfall #4: Catching all exceptions with catch (e) and showing a generic error screen.
Remedy: Map HTTP status codes to distinct domain failure classes (NetworkFailure, AuthFailure, ServerFailure).