Error Handling Patterns
Compare exceptions with result-based error modeling approaches
Error Handling Patterns in C#
Master robust error handling with free flashcards and spaced repetition practice. This lesson covers exception strategies, the Result pattern, Railway-Oriented Programming, and functional error handling techniquesβessential concepts for building resilient C# applications that gracefully handle failures.
Welcome π»
Error handling is one of the most critical aspects of professional software development. Poor error handling leads to crashes, data corruption, security vulnerabilities, and frustrated users. In C#, we have multiple paradigms for handling errors: traditional try-catch exceptions, functional Result types, Railway-Oriented Programming, and modern pattern matching.
This lesson will transform how you think about errorsβfrom "exceptional circumstances" to "expected outcomes" that should be explicitly modeled in your code. You'll learn when to use exceptions versus Result types, how to compose error-handling logic functionally, and how to create robust, maintainable error handling strategies.
Core Concepts π―
Exception-Based Error Handling
Exceptions are the traditional C# mechanism for error handling. When something goes wrong, you throw an exception, which unwinds the call stack until it's caught by a catch block.
public class Customer
{
public void UpdateEmail(string newEmail)
{
if (string.IsNullOrWhiteSpace(newEmail))
throw new ArgumentException("Email cannot be empty", nameof(newEmail));
if (!IsValidEmail(newEmail))
throw new ArgumentException("Invalid email format", nameof(newEmail));
Email = newEmail;
}
}
When to use exceptions:
- β True exceptional circumstances (disk full, network failure, hardware error)
- β Violations of invariants or contracts
- β Situations where the caller cannot reasonably be expected to check beforehand
- β NOT for control flow (e.g., parsing user input)
- β NOT for expected failure scenarios
π‘ Tip: Exceptions are expensive! They involve stack unwinding, building stack traces, and disrupting normal control flow. Use them judiciously.
The Problem with Exceptions
Exceptions have several drawbacks:
- Hidden control flow: Methods don't declare what exceptions they might throw (unlike Java's checked exceptions)
- Performance cost: Creating and throwing exceptions is slow
- Invisible in signatures:
public User GetUser(int id)doesn't tell you it might throwNotFoundException - Composition difficulties: Hard to chain operations that might fail
EXCEPTION CONTROL FLOW
βββββββββββββββ
β Method A β
β calls B ββββ
βββββββββββββββ β
β
βββββββββββββββ β
β Method B β β
β calls C ββββ€
βββββββββββββββ β
β
βββββββββββββββ β
β Method C β β
β throws! ββββ
βββββββββββββββ
β
β οΈ UNWIND!
β
βββββββββββββββ
β Catch in A β
β or Program β
β crashes β
βββββββββββββββ
The Result Pattern π
The Result pattern makes errors explicit in method signatures. Instead of throwing exceptions, methods return a Result<T> type that can be either success with a value or failure with an error.
public class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public string Error { get; }
private Result(bool isSuccess, T value, string error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value) =>
new Result<T>(true, value, null);
public static Result<T> Failure(string error) =>
new Result<T>(false, default(T), error);
}
Advantages of Result pattern:
- β Errors are visible in method signatures
- β No performance penalty
- β Forces callers to handle errors
- β Composable with LINQ and functional patterns
- β Predictable control flow
Usage example:
public Result<int> ParseAge(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result<int>.Failure("Age cannot be empty");
if (!int.TryParse(input, out int age))
return Result<int>.Failure("Age must be a valid number");
if (age < 0 || age > 150)
return Result<int>.Failure("Age must be between 0 and 150");
return Result<int>.Success(age);
}
// Usage
var result = ParseAge(userInput);
if (result.IsSuccess)
{
Console.WriteLine($"Age: {result.Value}");
}
else
{
Console.WriteLine($"Error: {result.Error}");
}
Enhanced Result with Error Types π
For more sophisticated error handling, use typed errors instead of strings:
public abstract class Error
{
public string Message { get; }
protected Error(string message) => Message = message;
}
public class ValidationError : Error
{
public ValidationError(string message) : base(message) { }
}
public class NotFoundError : Error
{
public NotFoundError(string message) : base(message) { }
}
public class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public Error Error { get; }
public static Result<T> Success(T value) =>
new Result<T> { IsSuccess = true, Value = value };
public static Result<T> Failure(Error error) =>
new Result<T> { IsSuccess = false, Error = error };
}
This allows pattern matching on error types:
var result = GetUser(userId);
var message = result.Error switch
{
ValidationError e => $"Invalid input: {e.Message}",
NotFoundError e => $"User not found: {e.Message}",
_ => "An unknown error occurred"
};
Railway-Oriented Programming π
Railway-Oriented Programming (ROP) is a functional pattern that treats your program like a railroad with two tracks: the success track and the failure track. Once you're on the failure track, you stay there.
RAILWAY-ORIENTED PROGRAMMING
SUCCESS TRACK: ββ[Op1]ββ[Op2]ββ[Op3]ββ[Op4]ββ β
Result
β β β β
β β β β
FAILURE TRACK: ββββββββββββββββββββββββββββββ β Error
Once you switch to failure track, you stay there!
Implement this with extension methods:
public static class ResultExtensions
{
// Bind: chains operations that return Result
public static Result<TOut> Bind<TIn, TOut>(
this Result<TIn> result,
Func<TIn, Result<TOut>> func)
{
return result.IsSuccess
? func(result.Value)
: Result<TOut>.Failure(result.Error);
}
// Map: transforms the value inside a successful Result
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> func)
{
return result.IsSuccess
? Result<TOut>.Success(func(result.Value))
: Result<TOut>.Failure(result.Error);
}
// Match: handles both success and failure cases
public static TOut Match<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> onSuccess,
Func<Error, TOut> onFailure)
{
return result.IsSuccess
? onSuccess(result.Value)
: onFailure(result.Error);
}
}
Pipeline example:
public Result<Order> ProcessOrder(OrderRequest request)
{
return ValidateRequest(request)
.Bind(CheckInventory)
.Bind(CalculatePrice)
.Bind(ChargeCustomer)
.Map(CreateOrderRecord);
}
// Each step returns Result<T>
// If any step fails, the rest are skipped
// Error propagates to the end automatically
This is declarative and composableβyou describe what should happen, and the railway infrastructure handles the error flow.
Either Type for Dual Outcomes βοΈ
The Either type is a generalization of Result that can hold one of two values: Left (typically error) or Right (typically success).
public class Either<TLeft, TRight>
{
private readonly TLeft _left;
private readonly TRight _right;
private readonly bool _isRight;
private Either(TLeft left)
{
_left = left;
_isRight = false;
}
private Either(TRight right)
{
_right = right;
_isRight = true;
}
public static Either<TLeft, TRight> Left(TLeft value) =>
new Either<TLeft, TRight>(value);
public static Either<TLeft, TRight> Right(TRight value) =>
new Either<TLeft, TRight>(value);
public TOut Match<TOut>(
Func<TLeft, TOut> leftFunc,
Func<TRight, TOut> rightFunc)
{
return _isRight ? rightFunc(_right) : leftFunc(_left);
}
}
// Usage with validation
public Either<ValidationError, User> CreateUser(string email, int age)
{
if (age < 18)
return Either<ValidationError, User>.Left(
new ValidationError("Must be 18 or older"));
var user = new User { Email = email, Age = age };
return Either<ValidationError, User>.Right(user);
}
Option Type for Missing Values π
The Option type (also called Maybe) represents a value that might be absent, avoiding null reference errors:
public class Option<T>
{
private readonly T _value;
public bool HasValue { get; }
private Option(T value, bool hasValue)
{
_value = value;
HasValue = hasValue;
}
public static Option<T> Some(T value) =>
new Option<T>(value, true);
public static Option<T> None() =>
new Option<T>(default(T), false);
public TOut Match<TOut>(
Func<T, TOut> some,
Func<TOut> none)
{
return HasValue ? some(_value) : none();
}
}
public Option<User> FindUserById(int id)
{
var user = _database.Users.FirstOrDefault(u => u.Id == id);
return user != null
? Option<User>.Some(user)
: Option<User>.None();
}
// Usage
var result = FindUserById(42).Match(
some: user => $"Found: {user.Name}",
none: () => "User not found"
);
Option vs null:
- Option makes absence explicit in the type system
- Compiler forces you to handle the None case
- No null reference exceptions
Practical Examples π§
Example 1: Parsing and Validation Pipeline
Let's build a complete user registration system using Result types:
public class UserRegistration
{
public Result<Email> ValidateEmail(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result<Email>.Failure("Email is required");
if (!input.Contains("@"))
return Result<Email>.Failure("Invalid email format");
return Result<Email>.Success(new Email(input));
}
public Result<Age> ValidateAge(string input)
{
if (!int.TryParse(input, out int age))
return Result<Age>.Failure("Age must be a number");
if (age < 18 || age > 120)
return Result<Age>.Failure("Age must be between 18 and 120");
return Result<Age>.Success(new Age(age));
}
public Result<User> CreateUser(Email email, Age age)
{
// Check if email already exists
if (_userRepository.EmailExists(email))
return Result<User>.Failure("Email already registered");
var user = new User
{
Email = email.Value,
Age = age.Value,
CreatedAt = DateTime.UtcNow
};
return Result<User>.Success(user);
}
public Result<User> RegisterUser(string emailInput, string ageInput)
{
// Railway-oriented pipeline
var emailResult = ValidateEmail(emailInput);
var ageResult = ValidateAge(ageInput);
// Combine two results
if (!emailResult.IsSuccess)
return Result<User>.Failure(emailResult.Error);
if (!ageResult.IsSuccess)
return Result<User>.Failure(ageResult.Error);
return CreateUser(emailResult.Value, ageResult.Value);
}
}
// Usage
var result = registration.RegisterUser(
emailInput: "john@example.com",
ageInput: "25"
);
var message = result.Match(
onSuccess: user => $"Welcome, {user.Email}!",
onFailure: error => $"Registration failed: {error}"
);
Key points:
- Each validation step returns a Result
- Errors are descriptive and specific
- No exceptions thrown for expected validation failures
- Easy to test each component independently
Example 2: Database Operations with Try-Catch vs Result
Compare traditional exception handling with Result pattern:
Traditional exception approach:
public class UserService
{
public User GetUser(int id)
{
try
{
var user = _database.Users.Find(id);
if (user == null)
throw new NotFoundException($"User {id} not found");
return user;
}
catch (SqlException ex)
{
_logger.LogError(ex, "Database error");
throw new DatabaseException("Failed to retrieve user", ex);
}
}
// Caller must remember to catch exceptions
public void DisplayUser(int id)
{
try
{
var user = GetUser(id);
Console.WriteLine(user.Name);
}
catch (NotFoundException)
{
Console.WriteLine("User not found");
}
catch (DatabaseException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}
Result pattern approach:
public class UserService
{
public Result<User> GetUser(int id)
{
try
{
var user = _database.Users.Find(id);
return user != null
? Result<User>.Success(user)
: Result<User>.Failure(new NotFoundError($"User {id} not found"));
}
catch (SqlException ex)
{
_logger.LogError(ex, "Database error");
return Result<User>.Failure(
new DatabaseError("Failed to retrieve user"));
}
}
// Signature forces caller to handle Result
public void DisplayUser(int id)
{
var result = GetUser(id);
var message = result.Error switch
{
NotFoundError => "User not found",
DatabaseError e => $"Database error: {e.Message}",
_ => result.IsSuccess
? result.Value.Name
: "Unknown error"
};
Console.WriteLine(message);
}
}
Benefits of Result approach:
- Type signature declares possible failure
- No hidden control flow
- Pattern matching for different error types
- Compiler ensures error handling
Example 3: Async Operations with Result
Combining async/await with Result pattern:
public class ApiClient
{
private readonly HttpClient _httpClient;
public async Task<Result<string>> FetchDataAsync(string url)
{
try
{
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return Result<string>.Failure(
new HttpError($"HTTP {response.StatusCode}"));
}
var content = await response.Content.ReadAsStringAsync();
return Result<string>.Success(content);
}
catch (HttpRequestException ex)
{
return Result<string>.Failure(
new NetworkError("Network error occurred"));
}
catch (TaskCanceledException)
{
return Result<string>.Failure(
new TimeoutError("Request timed out"));
}
}
public async Task<Result<Weather>> GetWeatherAsync(string city)
{
var url = $"https://api.weather.com/v1/{city}";
var result = await FetchDataAsync(url);
return result.IsSuccess
? ParseWeather(result.Value)
: Result<Weather>.Failure(result.Error);
}
private Result<Weather> ParseWeather(string json)
{
try
{
var weather = JsonSerializer.Deserialize<Weather>(json);
return Result<Weather>.Success(weather);
}
catch (JsonException)
{
return Result<Weather>.Failure(
new ParseError("Invalid weather data format"));
}
}
}
// Usage with async
var result = await apiClient.GetWeatherAsync("London");
await result.Match(
onSuccess: async weather =>
{
Console.WriteLine($"Temperature: {weather.Temp}Β°C");
await SaveToCache(weather);
},
onFailure: error =>
{
Console.WriteLine($"Failed: {error.Message}");
return Task.CompletedTask;
}
);
Async Result benefits:
- Clear error handling in async context
- No swallowed exceptions
- Composable async operations
- Type-safe error propagation
Example 4: Combining Multiple Results
When you need to combine multiple operations that might fail:
public static class ResultExtensions
{
// Combine two Results - both must succeed
public static Result<(T1, T2)> Combine<T1, T2>(
Result<T1> result1,
Result<T2> result2)
{
if (!result1.IsSuccess)
return Result<(T1, T2)>.Failure(result1.Error);
if (!result2.IsSuccess)
return Result<(T1, T2)>.Failure(result2.Error);
return Result<(T1, T2)>.Success((result1.Value, result2.Value));
}
// Collect multiple Results - all must succeed
public static Result<IEnumerable<T>> Sequence<T>(
this IEnumerable<Result<T>> results)
{
var values = new List<T>();
foreach (var result in results)
{
if (!result.IsSuccess)
return Result<IEnumerable<T>>.Failure(result.Error);
values.Add(result.Value);
}
return Result<IEnumerable<T>>.Success(values);
}
}
// Example: Validating multiple fields
public Result<Registration> ValidateRegistration(
string email,
string password,
string confirmPassword)
{
var emailResult = ValidateEmail(email);
var passwordResult = ValidatePassword(password);
var matchResult = ValidatePasswordsMatch(password, confirmPassword);
// Collect all validation results
var allResults = new[] { emailResult, passwordResult, matchResult };
var sequenceResult = allResults.Sequence();
return sequenceResult.Map(results => new Registration
{
Email = email,
PasswordHash = HashPassword(password)
});
}
Common Mistakes β οΈ
Mistake 1: Mixing Exceptions and Results
β Wrong:
public Result<User> GetUser(int id)
{
if (id <= 0)
throw new ArgumentException("Invalid ID"); // Exception!
var user = _database.Find(id);
return user != null
? Result<User>.Success(user)
: Result<User>.Failure("Not found"); // Result!
}
β Right:
public Result<User> GetUser(int id)
{
if (id <= 0)
return Result<User>.Failure("Invalid ID"); // Consistent!
var user = _database.Find(id);
return user != null
? Result<User>.Success(user)
: Result<User>.Failure("Not found");
}
Lesson: Choose one strategy per method. Don't mix exceptions and Resultsβit creates confusion about how errors are communicated.
Mistake 2: Accessing Value Without Checking IsSuccess
β Wrong:
var result = ParseAge(input);
Console.WriteLine(result.Value); // Might be default(T)!
β Right:
var result = ParseAge(input);
if (result.IsSuccess)
{
Console.WriteLine(result.Value);
}
else
{
Console.WriteLine(result.Error);
}
// Or use Match
var output = result.Match(
onSuccess: age => $"Age: {age}",
onFailure: error => $"Error: {error}"
);
Lesson: Always check IsSuccess before accessing Value. Better yet, use the Match method to force handling both cases.
Mistake 3: Using Empty Catch Blocks
β Wrong:
try
{
ProcessData();
}
catch
{
// Silently swallow exception
}
β Right:
try
{
ProcessData();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process data");
// Re-throw or return error Result
throw;
}
Lesson: Never silently swallow exceptions. At minimum, log them. Consider whether the exception should propagate or be converted to a Result.
Mistake 4: Creating Vague Error Messages
β Wrong:
return Result<User>.Failure("Error");
return Result<User>.Failure("Invalid");
return Result<User>.Failure("Failed");
β Right:
return Result<User>.Failure("Email format is invalid: missing @ symbol");
return Result<User>.Failure("Age must be between 18 and 120");
return Result<User>.Failure("Database connection timeout after 30 seconds");
Lesson: Error messages should be specific, actionable, and helpful for debugging. Include context like invalid values, constraints, and what went wrong.
Mistake 5: Not Using Typed Errors
β Wrong:
public Result<T> { string Error { get; } }
// Can't distinguish error types
if (result.Error.Contains("not found"))
// String matching is fragile!
β Right:
public abstract class Error { }
public class NotFoundError : Error { }
public class ValidationError : Error { }
public Result<T> { Error Error { get; } }
// Pattern matching on type
var action = result.Error switch
{
NotFoundError => HandleNotFound(),
ValidationError => HandleValidation(),
_ => HandleUnknown()
};
Lesson: Use typed errors instead of strings when you need to handle different error types differently. This enables pattern matching and type safety.
Mistake 6: Over-Using Exceptions for Flow Control
β Wrong:
public int ParseOrDefault(string input)
{
try
{
return int.Parse(input); // Throws on invalid input
}
catch
{
return 0; // Using exceptions for expected failure
}
}
β Right:
public int ParseOrDefault(string input)
{
return int.TryParse(input, out int result) ? result : 0;
}
// Or with Result
public Result<int> ParseInt(string input)
{
return int.TryParse(input, out int result)
? Result<int>.Success(result)
: Result<int>.Failure("Invalid number format");
}
Lesson: Exceptions are for exceptional circumstances. Use TryParse, Result types, or bool return values for expected failure scenarios.
Key Takeaways π
Choose the right tool: Use exceptions for truly exceptional situations, Result types for expected failures
Make errors explicit: Result
makes potential failures visible in method signatures Railway-Oriented Programming: Chain operations with Bind/Map to handle errors elegantly
Type your errors: Use error classes instead of strings for better pattern matching and type safety
Option for absence: Use Option
instead of null to avoid null reference exceptions Compose operations: Result types are composableβuse Bind, Map, and Sequence to build pipelines
Be consistent: Don't mix exceptions and Results in the same method or API layer
Provide context: Error messages should be specific and actionable
Test error paths: Error handling is code tooβwrite tests for failure scenarios
Performance matters: Exceptions are slow; Result types have minimal overhead
π Quick Reference: Error Handling Decision Tree
Should I throw an exception?
β
β
Is it truly exceptional?
(hardware failure, programming error)
β
ββββββ΄βββββ
β β
YES NO
β β
β β
THROW Is it expected?
EXCEPTION (parsing, validation)
β
ββββββ΄βββββ
β β
YES NO
β β
β β
USE RESULT USE OPTION
PATTERN (for absence)
| Pattern | Use When | Example |
|---|---|---|
| Exception | Truly exceptional, can't continue | OutOfMemoryException, disk full |
| Result<T> | Expected failure, multiple error types | Validation, parsing, business rules |
| Option<T> | Value might be absent | Database lookup, dictionary access |
| Either<L,R> | Two distinct outcomes | Success/Failure with different types |
| bool return | Simple success/failure, no details needed | TryParse, file exists check |
π Further Study
- Microsoft Docs: Exception Handling (C#) - Official C# exception handling guide
- Railway Oriented Programming by Scott Wlaschin - Deep dive into functional error handling
- CSharpFunctionalExtensions Library - Production-ready Result and Option types for C#
π‘ Remember: Error handling isn't just about catching errorsβit's about designing your code to make invalid states unrepresentable and guiding callers toward correct usage. Master these patterns, and your code will be more robust, maintainable, and pleasant to work with!