惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
L
LINUX DO - 热门话题
Blog — PlanetScale
Blog — PlanetScale
博客园 - Franky
J
Java Code Geeks
腾讯CDC
博客园 - 聂微东
The Cloudflare Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 司徒正美
Last Week in AI
Last Week in AI
量子位
Stack Overflow Blog
Stack Overflow Blog
Microsoft Security Blog
Microsoft Security Blog
Google DeepMind News
Google DeepMind News
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
S
Schneier on Security
C
CERT Recently Published Vulnerability Notes
Latest news
Latest news
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
有赞技术团队
有赞技术团队
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
S
Securelist
AWS News Blog
AWS News Blog
GbyAI
GbyAI
L
LINUX DO - 最新话题
大猫的无限游戏
大猫的无限游戏
Forbes - Security
Forbes - Security
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Attack and Defense Labs
Attack and Defense Labs
C
CXSECURITY Database RSS Feed - CXSecurity.com
Y
Y Combinator Blog
W
WeLiveSecurity
T
Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
P
Proofpoint News Feed
D
DataBreaches.Net
博客园 - 三生石上(FineUI控件)
V
V2EX
N
News and Events Feed by Topic
Google DeepMind News
Google DeepMind News
D
Docker
The Hacker News
The Hacker News
A
About on SuperTechFans
Security Latest
Security Latest
NISL@THU
NISL@THU
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Cisco Talos Blog
Cisco Talos Blog
博客园_首页
H
Hacker News: Front Page

freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More

Learn Command Line Interface (CLI) Development with Dart: From Zero to a Fully Published Developer Tool How to Build a Live Options Database in Python – A Complete Guide How to Migrate to S3 Native State Locking in Terraform How to Use SCons to Build Software Projects [Full Handbook] How to Run Open Source LLMs Locally and in the Cloud QuRT: The Real-Time OS Inside Your Phone's Processor [Full Handbook] The Real Infrastructure Behind Remote Work (It’s Not Just Wi-Fi) The Lithography Handbook: Machines, Markets, and the Next Wave of Semiconductor Startups ITCM vs DTCM vs DDR: Embedded Memory Types Explained [Full Handbook] AI Paper Review: Improving Language Understanding by Generative Pre-Training (GPT-1) How to Build a Market Research Copilot with MCP and Python [Full Handbook] How to Build a Scoped Note-Taking API with Django Rest Framework and SimpleJWT The Complete SOC 2 Type II Implementation Handbook for Engineers: A Month-by-Month Roadmap with Real Commands Mastering the JavaScript Event Loop Data Science Insights: Why the Mean Lies When Handling Messy Retail Data How to Build High-Ranking SEO Landing Page How to Query Data in DynamoDB Using .Net How to Unblock Your AI PR Review Bottleneck: A Tech Lead’s Guide to Building a Codebase-Aware Reviewer How to Navigate Microservices as a Frontend Engineer How to Compress PDF Files in the Browser Using JavaScript (Step-by-Step) Stanford's youngest instructor talks InfoSec, AI, and catching cheaters - Rachel Fernandez interview [Podcast #217] Product Experimentation with Propensity Scores: Causal Inference for LLM-Based Features in Python How to Build a Multi-Agent AI System with LangGraph, MCP, and A2A [Full Book] How to Land Your First Cloud or DevOps Role: What Hiring Managers Actually Look For How to Deploy a Serverless Spam Classifier Using Scikit-Learn, AWS Lambda, & API Gateway How to Dockerize a Go Application – Full Step-by-Step Walkthrough Learn Hardware, Cloud, DevOps, Networking, Security, Databases, DNS, Git, and Linux Inside TreeHacks 2026, Stanford’s Elite Student Hakc Inside Stanford’s Elite Student Hackathon [Full Documentary] How to Measure Your AI Citation Rate Across ChatGPT, Perplexity, and Claude How to Deploy a Full-Stack Next.js App on Cloudflare Workers with GitHub Actions CI/CD How to Build a Multi-Tenant SaaS Platform with Next.js, Express, and Prisma How I Completed 15 freeCodeCamp Certifications in 4 Months: A Structured Learning Journey How to Build an Agentic Terminal Workflow with GitHub Copilot CLI and MCP Servers How AI Changed the Economics of Writing Clean Code How to Apply STRIDE Threat Modeling and SonarQube Analysis for Secure Software Development How to Set Up OpenID Connect (OIDC) in GitHub Actions for AWS How to Split PDF Files in the Browser Using JavaScript (Step-by-Step) How to Build Your Own Language-Specific LLM [Full Handbook] How to Build a Self-Learning RAG System with Knowledge Reflection How to Trace Multi-Agent AI Swarms with Jaeger v2 How I Tested Malaysia's Open Data Portals with Plain English How I Built a Production-Ready CI/CD Pipeline for a Monorepo-Based Microservices System with Jenkins, Docker Compose, and Traefik The Hidden Tax of Infrastructure: Why Your Team Shouldn’t Be Running It Anymore From Metrics to Meaning: How PaaS Helps Developers Understand Production From Symptoms to Root Cause: How to Use the 5 Whys Technique Product Experimentation for AI Rollouts: Why A/B Testing Breaks and How Difference-in-Differences in Python Fixes It How to Create a GPU-Optimized Machine Image with HashiCorp Packer on GCP 3D Web Development with Blender and Three.js How to Fix a Failing GitHub PR: Debugging CI, Lint Errors, and Build Errors Step by Step How to Merge PDF Files in the Browser Using JavaScript (Step-by-Step) How to Handle Stripe Webhooks Reliably with Background Jobs How to Build an Automatic Knowledge Graph for Your Blog with PHP and JSON-LD Understanding Proxies and Reverse Proxies: Your Gateway to Secure Networking The Evolution of Nvidia Blackwell GPU Memory Architecture How to Use PostgreSQL as a Cache, Queue, and Search Engine The New Definition of Software Engineering in the Age of AI Reclaim Your Time – Master Automation with Zapier How to Create Dynamic Emails in Go with React Email Why Many Beginner Self-Taught Developers Struggle (And What to Do About It) How to Build a Headless WordPress Frontend with Astro SSR on Cloudflare Pages How to Make Your GitHub Profile Stand Out How to Use Context Hub (chub) to Build a Companion Relevance Engine Why Chrome OS Is the Operating System the AI Era Was Built For How to Build Microservices-Based REST APIs for Healthcare Portals How to friction-max your learning with software engineer Jessica Rose [Podcast #216] Shadow AI Explained: Why Employees Are Using AI Behind Your Back Traditional Scraping vs AI Scraping: A Practical Guide for Developers and Data Teams How Database Indexes Work – A Practical Guide with PostgreSQL Examples How to Streamline Search in Web Applications with Elasticsearch How to Build an Open Source Data Lake for Batch Ingestion OpenAI Codex Essentials – AI Assisted Agentic Development Course Learn Software System Design How to Generate PDF Files in the Browser Using JavaScript (With a Real Invoice Example) How to Get Started with Terraform Service-to-Service Communication: When to Use REST, gRPC, and Event-Driven Messaging A Developer’s Guide to Lazy Loading in React and Next.js The Data Quality Handbook: Data Errors, the Developer's Role, and Validation Layers Explained. United States Residential Proxy: Why Local IP Accuracy Matters for SERP, Ads, and Pricing How to Build a Fashion App That Helps You Organize Your Wardrobe How to Build an Admin Dashboard Sidebar with shadcn/ui and Base UI The AI Governance Handbook: How to Build Responsible AI Systems That Actually Ship How to Build a Local DevOps HomeLab with Docker, Kubernetes, and Ansible How to Use Mixins in Flutter [Full Handbook] How to Prep for Technical Interviews – A Guide for Web Developers GPT-5.4 vs GLM-5: Is Open Source Finally Matching Proprietary AI? Data Visualization Tools for Svelte Developers How to Keep Human Experts Visible in Your AI-Assisted Codebase Efficient Data Processing in Python: Batch vs Streaming Pipelines Explained How to Build and Deploy Multi-Architecture Docker Apps on Google Cloud Using ARM Nodes (Without QEMU) How to Build a Secure AI PR Reviewer with Claude, GitHub Actions, and JavaScript How to Build a Positioning-Based Crude Oil Strategy in Python [Full Handbook] How to learn programming and CS in the AI hype era – interview with dev and prof Mark Mahoney [Podcast #215] CUDA Programming for NVIDIA H100s How to Build Reliable AI Systems. How to Build an Online Marketplace with Next.js, Express, and Stripe Connect How to Build a Cost-Efficient AI Agent with Tiered Model Routing The WebCodecs Handbook: Native Video Processing in the Browser The Bluetooth LE Audio Handbook: From "Why Does My Call Sound Like a Tin Can?" to AOSP Implementation How to Set Up OpenClaw and Design an A2A Plugin Bridge
How to Handle Errors the Right Way in Flutter: A Practical Guide to Sealed Classes, Records, and Result Types
Gidudu Nicholas · 2026-06-21 · via freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
How to Handle Errors the Right Way in Flutter: A Practical Guide to Sealed Classes, Records, and Result Types

I used to think I was handling errors well in my Flutter apps. I had try/catch blocks everywhere. I was catching exceptions, logging them, and showing error messages to users. It felt solid.

Then I started looking more carefully at what was actually happening in production. There were silent failures I never knew about. Functions that could throw but nothing in the type system warned you about it. Error handling scattered inconsistently across the codebase — some places caught errors, others didn't.

A junior developer on the team added a new API call and forgot the try/catch entirely, and nobody caught it in review because there was nothing in the code that said "this function can fail."

That's when I started taking error handling seriously as an architectural decision, not just a defensive habit.

This article covers the patterns I now use in production Flutter apps — Result types, sealed classes, Dart 3 records, and pattern matching — and how they work together to make errors visible, explicit, and impossible to ignore.

Table of Contents

  • Why try/catch Alone Isn't Enough

  • Errors as Values: the Core Idea

  • Building a Result Type with Sealed Classes

  • Dart 3 Records and What They Add

  • Pattern Matching on Errors

  • Applying This to a Real Bloc Feature

  • When This Approach is Worth it and When it Isn't

  • End-to-End Example

  • Final Thoughts

Why try/catch Alone Isn't Enough

Try/catch works. I'm not saying it doesn't. For simple cases it's perfectly fine. But as your app grows, relying on try/catch as your primary error handling strategy creates a specific set of problems that only become obvious at scale.

The problem is invisibility.

When a function can throw an exception, there's nothing in its signature that tells you that. Look at this:

Future<User> getUser(String userId) async {
  final response = await dio.get('/users/$userId');
  return User.fromJson(response.data);
}

This function looks like it always returns a User. Nothing about its signature suggests it might fail. A developer calling this function has no idea whether to wrap it in a try/catch unless they read the implementation or have been burned by it before.

Now imagine this function is called in ten different places across your app. Some developers remember to handle errors. Others don't. There's no compiler warning, no lint rule, nothing to catch the inconsistency. The errors are invisible until a user reports a crash.

The second problem is that exceptions are contagious.

When a function throws, every caller has to handle it. And every caller of those callers. The error handling responsibility spreads outward through your codebase, often inconsistently. Some layers swallow exceptions silently. Others re-throw them. The flow of errors through your app becomes hard to reason about.

The third problem is that not all errors are exceptional.

A network request failing isn't an exceptional event in a mobile app. It's expected. Treating it as an exception — something abnormal that interrupts the normal flow — is the wrong mental model. It's a normal outcome that should be handled like any other outcome.

This is the core insight behind Result types: errors are values, not interruptions.

Errors as Values: the Core Idea

The idea is simple. Instead of a function either returning a value or throwing an exception, it always returns a value — but that value can represent either success or failure.

// Instead of this — may or may not throw
Future<User> getUser(String userId);

// We write this — always returns a result
Future<Result<User>> getUser(String userId);

Now the function signature is honest. It tells you "this operation can succeed or fail, and you have to deal with both." The compiler enforces that you handle both cases. There's no way to accidentally ignore the failure path.

This pattern comes from languages like Rust and Kotlin where it's built into the standard library. In Dart we build it ourselves — and with sealed classes and pattern matching in Dart 3, it's cleaner than ever.

Building a Result Type with Sealed Classes

Here's the Result type I use in production:

// result.dart

// Sealed means every possible subtype is defined
// right here in this file. The compiler knows
// there are exactly two possible outcomes —
// Success and Failure — and nothing else.
sealed class Result<T> {}

// Success carries the value we wanted.
// T is the type parameter — Result<User> means
// Success carries a User, Result<List<Post>> carries a list.
class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

// Failure carries an AppError describing what went wrong.
// We use a typed error class rather than a raw exception
// so the UI can make decisions based on the error type.
class Failure<T> extends Result<T> {
  final AppError error;
  const Failure(this.error);
}

Now we need a typed error class. Instead of passing raw exception messages around, we define the specific errors our app can produce:

// app_error.dart

// AppError is also sealed — every error type our app
// can produce is defined here. This makes it impossible
// to have an unhandled error type slip through.
sealed class AppError {}

// No internet connection
class NoInternetError extends AppError {}

// The server returned an error response
class ServerError extends AppError {
  final int statusCode;
  final String message;
  const ServerError({required this.statusCode, required this.message});
}

// The data came back in an unexpected format
class ParseError extends AppError {
  final String message;
  const ParseError(this.message);
}

// Something unexpected happened that we didn't anticipate
class UnknownError extends AppError {
  final String message;
  const UnknownError(this.message);
}

Now let's use this in a repository:

// post_repository.dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:dio/dio.dart';
import 'result.dart';
import 'app_error.dart';
import 'post.dart';

class PostRepository {
  final Dio _dio;
  PostRepository(this._dio);

  Future<Result<List<Post>>> getPosts() async {
    try {
      final response = await _dio.get(
        'https://jsonplaceholder.typicode.com/posts',
      );

      // Parse the response into a list of Post objects.
      // We wrap this in its own try/catch because parsing
      // can fail independently of the network call —
      // the API might return valid JSON but in an unexpected shape.
      try {
        final List<dynamic> data = response.data as List<dynamic>;
        final posts = data
            .map((json) => Post.fromJson(json as Map<String, dynamic>))
            .toList();

        // Wrap the success value in Success<List<Post>>
        // and return it. The caller receives a Result,
        // not a raw list, so they know they have to
        // check whether it succeeded or failed.
        return Success(posts);
      } catch (e) {
        return Failure(ParseError('Failed to parse posts: $e'));
      }
    } on DioException catch (e) {
      // Map Dio's exception types to our own AppError types.
      // This keeps Dio-specific types out of the rest of the app.
      // If we ever swap Dio for a different HTTP client,
      // only this file needs to change.
      if (e.type == DioExceptionType.connectionError) {
        return Failure(NoInternetError());
      }

      return Failure(
        ServerError(
          statusCode: e.response?.statusCode ?? 0,
          message: e.message ?? 'Server error',
        ),
      );
    } catch (e) {
      // Catch-all for anything unexpected
      return Failure(UnknownError(e.toString()));
    }
  }
}

Notice what changed. The function signature Future<Result<List<Post>>> is now honest. Anyone calling getPosts() knows they're getting a Result — they can't pretend it always succeeds. And the try/catch is contained entirely inside the repository. Nothing leaks out to the callers.

Dart 3 Records and What They Add

Before we get to pattern matching, it's worth talking about Dart 3 records because they pair naturally with Result types.

A record is a lightweight, anonymous object that groups multiple values together without needing to define a full class. Think of it as a quick way to return multiple values from a function.

// Before records — you needed a class or a Map
// to return multiple values
Map<String, dynamic> getUserInfo() {
  return {'name': 'Nicholas', 'age': 28};
  // No type safety — 'age' could be anything
}

// With records — type safe, no class needed
(String name, int age) getUserInfo() {
  return ('Nicholas', 28);
  // The compiler knows name is a String and age is an int
}

Records become useful in error handling when you need to return a value and some metadata alongside it:

// A function that returns a post and its fetch timestamp
Future<Result<(Post, DateTime)>> getPostWithTimestamp(
  String postId,
) async {
  try {
    final response = await _dio.get('/posts/$postId');
    final post = Post.fromJson(response.data);

    // The record (post, DateTime.now()) groups both values
    // without needing a wrapper class
    return Success((post, DateTime.now()));
  } catch (e) {
    return Failure(UnknownError(e.toString()));
  }
}

And consuming it:

final result = await repository.getPostWithTimestamp('1');

switch (result) {
  case Success(:final data):
    // Destructure the record directly in the pattern
    final (post, fetchedAt) = data;
    print('Got \({post.title} at \)fetchedAt');
  case Failure(:final error):
    print('Failed: $error');
}

Records are not essential for Result types but they remove the need for small helper classes that exist purely to carry two or three values together. I use them regularly in repository methods that need to return data alongside pagination cursors or cache metadata.

Pattern Matching on Errors

This is where everything comes together. Sealed classes plus pattern matching means the compiler forces you to handle every possible outcome. You can't accidentally ignore the failure case.

final result = await repository.getPosts();

switch (result) {
  // Named field pattern — extracts 'data' directly
  // from Success without a manual cast
  case Success(:final data):
    print('Got ${data.length} posts');

  case Failure(:final error):
    // Now pattern match on the error type
    // to give the user the right message
    switch (error) {
      case NoInternetError():
        print('No internet connection. Please check your connection.');
      case ServerError(:final statusCode, :final message):
        print('Server error \(statusCode: \)message');
      case ParseError(:final message):
        print('Something went wrong parsing the data: $message');
      case UnknownError(:final message):
        print('Unexpected error: $message');
    }
}

Both switch statements are exhaustive. If you add a new Result subtype and forget to handle it here, you get a compile error. Add a new AppError subtype and forget to handle it here, you get a compile error. The compiler is working as your quality control.

You can also use the when extension pattern for more concise handling:

// A helper extension that makes Result easier to consume
extension ResultExtension<T> on Result<T> {
  // Runs onSuccess if this is a Success,
  // runs onFailure if this is a Failure
  R when<R>({
    required R Function(T data) onSuccess,
    required R Function(AppError error) onFailure,
  }) {
    return switch (this) {
      Success(:final data) => onSuccess(data),
      Failure(:final error) => onFailure(error),
    };
  }

  // Returns the data if Success, null if Failure
  T? getOrNull() => switch (this) {
    Success(:final data) => data,
    Failure() => null,
  };

  // Returns true if this is a Success
  bool get isSuccess => this is Success<T>;

  // Returns true if this is a Failure
  bool get isFailure => this is Failure<T>;
}

Usage becomes very clean:

final result = await repository.getPosts();

final posts = result.when(
  onSuccess: (data) => data,
  onFailure: (error) => <Post>[],
);

Applying This to a Real Bloc Feature

Let's wire everything into a complete Bloc. We'll use the posts feature we built in the previous session and upgrade it to use Result types.

The states, now with sealed classes:

// post_state.dart

sealed class PostState {}

class PostInitial extends PostState {}

class PostLoading extends PostState {}

// Success state carries the posts directly
class PostLoaded extends PostState {
  final List<Post> posts;
  const PostLoaded(this.posts);
}

// Error state carries a typed AppError, not just a string.
// This means the UI can make decisions based on the
// error type — show a "no internet" message vs a
// "server error" message vs a "try again" message.
class PostError extends PostState {
  final AppError error;
  const PostError(this.error);
}

The Bloc:

// post_bloc.dart

class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository _repository;

  PostBloc(this._repository) : super(PostInitial()) {
    on<LoadPosts>(_onLoadPosts);
  }

  Future<void> _onLoadPosts(
    LoadPosts event,
    Emitter<PostState> emit,
  ) async {
    emit(PostLoading());

    // getPosts() now returns Result<List<Post>>
    // We pattern match on the result directly —
    // no try/catch needed here because the repository
    // already handles all error cases and wraps them
    // in a Failure. The Bloc just reads the result.
    final result = await _repository.getPosts();

    switch (result) {
      case Success(:final data):
        emit(PostLoaded(data));
      case Failure(:final error):
        emit(PostError(error));
    }
  }
}

Notice there's no try/catch in the Bloc at all. The repository owns error handling. The Bloc just reads the Result and emits the right state. It's clean, simple, and each layer doing exactly one job.

The UI:

// post_screen.dart

BlocBuilder<PostBloc, PostState>(
  builder: (context, state) {
    return switch (state) {
      PostInitial() => const Center(
          child: Text('Press the button to load posts'),
        ),

      PostLoading() => const Center(
          child: CircularProgressIndicator(),
        ),

      PostLoaded(:final posts) => ListView.builder(
          itemCount: posts.length,
          itemBuilder: (context, index) {
            final post = posts[index];
            return ListTile(
              leading: Text('${post.id}'),
              title: Text(post.title),
              subtitle: Text(post.body),
            );
          },
        ),

      // Pattern match on the error type to show
      // the right message for each specific error.
      // This is something try/catch cannot give you —
      // typed, structured errors that the UI can act on.
      PostError(:final error) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                switch (error) {
                  NoInternetError() =>
                    'No internet connection. Please check your connection.',
                  ServerError(:final statusCode) =>
                    'Server error ($statusCode). Please try again.',
                  ParseError() =>
                    'Something went wrong. Please try again.',
                  UnknownError() =>
                    'An unexpected error occurred.',
                },
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  context.read<PostBloc>().add(LoadPosts());
                },
                child: const Text('Try again'),
              ),
            ],
          ),
        ),
    };
  },
)

The UI now shows a different message for each error type. A user with no internet gets a different message than a user who hit a server error. That's a much better user experience than a generic "something went wrong". And it comes directly from having typed errors rather than raw exception messages.

When This Approach Is Worth It and When It Isn't

I want to be honest here, because I've seen developers over-engineer simple things in the name of good architecture.

Use Result types when:

  • The function can fail in multiple distinct ways that the caller needs to handle differently

  • You're building a repository or service layer that multiple features depend on

  • You're working in a team where inconsistent error handling is a real problem

  • The feature involves money, user data, or anything where silent failures are dangerous

Stick with try/catch when:

  • It's a simple, one-off operation in a small feature

  • The error handling is the same regardless of what went wrong: show a message, log it, done

  • You're prototyping or in early development and the architecture is still changing

  • The added complexity isn't justified by the size of the codebase

The Result type pattern adds ceremony. There's no point denying that. A simple try/catch is less code. The tradeoff is that try/catch is invisible — nothing enforces that callers handle errors. Result types are explicit — the type system enforces it.

For production apps that serve real users and have more than one developer working on them, that explicitness is worth the extra code. For a side project you're building alone, it might be overkill.

End-to-End Example

Here's everything together in one complete feature. Copy this into a new Flutter project and run it.

Folder structure:

lib/
  core/
    result.dart
    app_error.dart
  models/
    post.dart
  data/
    post_repository.dart
  bloc/
    post_bloc.dart
    post_event.dart
    post_state.dart
  ui/
    post_screen.dart
  main.dart

result.dart:

sealed class Result<T> {}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

class Failure<T> extends Result<T> {
  final AppError error;
  const Failure(this.error);
}

extension ResultExtension<T> on Result<T> {
  R when<R>({
    required R Function(T data) onSuccess,
    required R Function(AppError error) onFailure,
  }) {
    return switch (this) {
      Success(:final data) => onSuccess(data),
      Failure(:final error) => onFailure(error),
    };
  }
}

app_error.dart:

sealed class AppError {}

class NoInternetError extends AppError {}

class ServerError extends AppError {
  final int statusCode;
  final String message;
  const ServerError({required this.statusCode, required this.message});
}

class ParseError extends AppError {
  final String message;
  const ParseError(this.message);
}

class UnknownError extends AppError {
  final String message;
  const UnknownError(this.message);
}

post.dart:

class Post {
  final int id;
  final String title;
  final String body;
  final int userId;

  const Post({
    required this.id,
    required this.title,
    required this.body,
    required this.userId,
  });

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,
      title: json['title'] as String,
      body: json['body'] as String,
      userId: json['userId'] as int,
    );
  }
}

post_repository.dart:

import 'package:dio/dio.dart';
import '../core/result.dart';
import '../core/app_error.dart';
import '../models/post.dart';

class PostRepository {
  final Dio _dio;
  PostRepository(this._dio);

  Future<Result<List<Post>>> getPosts() async {
    try {
      final response = await _dio.get(
        'https://jsonplaceholder.typicode.com/posts',
      );

      try {
        final List<dynamic> data = response.data as List<dynamic>;
        final posts = data
            .map((json) => Post.fromJson(json as Map<String, dynamic>))
            .toList();
        return Success(posts);
      } catch (e) {
        return Failure(ParseError('Failed to parse posts: $e'));
      }
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionError) {
        return Failure(NoInternetError());
      }
      return Failure(
        ServerError(
          statusCode: e.response?.statusCode ?? 0,
          message: e.message ?? 'Server error',
        ),
      );
    } catch (e) {
      return Failure(UnknownError(e.toString()));
    }
  }
}

post_event.dart:

sealed class PostEvent {}

class LoadPosts extends PostEvent {}

post_state.dart:

import '../core/app_error.dart';
import '../models/post.dart';

sealed class PostState {}

class PostInitial extends PostState {}
class PostLoading extends PostState {}

class PostLoaded extends PostState {
  final List<Post> posts;
  const PostLoaded(this.posts);
}

class PostError extends PostState {
  final AppError error;
  const PostError(this.error);
}

post_bloc.dart:

import 'package:flutter_bloc/flutter_bloc.dart';
import '../core/result.dart';
import '../data/post_repository.dart';
import 'post_event.dart';
import 'post_state.dart';

class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository _repository;

  PostBloc(this._repository) : super(PostInitial()) {
    on<LoadPosts>(_onLoadPosts);
  }

  Future<void> _onLoadPosts(
    LoadPosts event,
    Emitter<PostState> emit,
  ) async {
    emit(PostLoading());

    final result = await _repository.getPosts();

    switch (result) {
      case Success(:final data):
        emit(PostLoaded(data));
      case Failure(:final error):
        emit(PostError(error));
    }
  }
}

post_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';
import '../bloc/post_state.dart';
import '../core/app_error.dart';

class PostScreen extends StatelessWidget {
  const PostScreen({super.key});

  String _errorMessage(AppError error) {
    return switch (error) {
      NoInternetError() =>
        'No internet connection. Please check your connection.',
      ServerError(:final statusCode) =>
        'Server error ($statusCode). Please try again.',
      ParseError() => 'Something went wrong. Please try again.',
      UnknownError() => 'An unexpected error occurred.',
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: BlocBuilder<PostBloc, PostState>(
        builder: (context, state) {
          return switch (state) {
            PostInitial() => const Center(
                child: Text('Press the button to load posts'),
              ),
            PostLoading() => const Center(
                child: CircularProgressIndicator(),
              ),
            PostLoaded(:final posts) => ListView.builder(
                itemCount: posts.length,
                itemBuilder: (context, index) {
                  final post = posts[index];
                  return ListTile(
                    leading: Text('${post.id}'),
                    title: Text(post.title),
                    subtitle: Text(post.body),
                  );
                },
              ),
            PostError(:final error) => Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(_errorMessage(error)),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: () {
                        context.read<PostBloc>().add(LoadPosts());
                      },
                      child: const Text('Try again'),
                    ),
                  ],
                ),
              ),
          };
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<PostBloc>().add(LoadPosts()),
        child: const Icon(Icons.download),
      ),
    );
  }
}

main.dart:

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/post_bloc.dart';
import 'data/post_repository.dart';
import 'ui/post_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Result Type Demo',
      home: BlocProvider(
        create: (_) => PostBloc(PostRepository(Dio())),
        child: const PostScreen(),
      ),
    );
  }
}

Final Thoughts

None of this is about being clever or following a pattern for its own sake. It's about making errors visible.

The fundamental problem with try/catch as your only error handling tool is that it hides the possibility of failure behind a normal-looking function signature. Result types surface that possibility in the type system, where the compiler can help you handle it consistently.

The combination of sealed classes, typed errors, pattern matching, and Dart 3 records gives you a system where:

  • Functions are honest about what they can return

  • Every error type is handled explicitly

  • Adding a new error type automatically breaks every switch that doesn't handle it

  • The UI can show the right message for the right error

I wish I'd built my first production app this way. It would have saved me a lot of time tracking down silent failures and inconsistent error states.

If you're already comfortable with try/catch and want to take your error handling to the next level, start small. Add a Result type to one repository. See how it feels. The pattern tends to spread naturally once you experience the clarity it brings.



Learn to code for free. freeCodeCamp's open source curriculum has helped more than 40,000 people get jobs as developers. Get started