Skip to main content
Back to all tutorials
Tested: Flutter 3.24.3 • Dart 3.5.3
T06.01 Verified September 2026
HTTP Networking JSON API Inspector Error Handling RFC 8259

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 Tested Flutter Code

main.dart (Flutter 3.x+ ready)
lib/main.dart Copy & Paste into a new Flutter project
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.

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).

Official Flutter References & Standards