Swift Error Design Patterns
Error handling is a critical aspect of writing robust and reliable Swift applications. While Swift provides a powerful error handling system, implementing it effectively requires understanding common patterns and best practices. This guide explores various error design patterns that will help you handle errors in a structured, maintainable way.
Introduction to Error Design Patterns
Error design patterns are established approaches to handling errors that solve common problems in error management. These patterns help you:
- Create more readable and maintainable code
- Provide meaningful feedback to users
- Isolate error-prone code
- Make debugging easier
- Create consistent error handling across your application
Let's explore the most useful error design patterns in Swift and how to implement them effectively.
Common Swift Error Types
Before diving into patterns, let's understand the typical ways to define errors in Swift.
Pattern 1: Simple Enumeration Errors
The most basic pattern is using Swift's enum
conforming to the Error
protocol:
enum NetworkError: Error {
case connectionFailed
case serverError(statusCode: Int)
case invalidData
case authenticationFailure
}
This approach is clean, type-safe, and allows you to categorize errors.
When to use this pattern:
- For domain-specific errors within a single subsystem
- When errors have a limited, well-defined set of cases
- When additional context isn't needed beyond the error type and associated values
Pattern 2: Structured Error Types
For more complex error handling needs, you can create structured error types with additional context:
struct APIError: Error {
let statusCode: Int
let message: String
let underlyingError: Error?
var localizedDescription: String {
return "API Error (\(statusCode)): \(message)"
}
}
// Usage
let error = APIError(
statusCode: 404,
message: "Resource not found",
underlyingError: nil
)
When to use this pattern:
- When you need to include rich error context
- For errors that need to carry structured data
- When implementing custom error reporting systems
Error Propagation Patterns
How you pass errors through your application is as important as how you define them.
Pattern 3: The Bubble-Up Pattern
This pattern involves letting errors "bubble up" the call stack until they reach code that can handle them:
func fetchUserProfile(userId: String) throws -> UserProfile {
// This function propagates errors from the functions it calls
let userData = try fetchUserData(userId)
return try parseUserProfile(userData)
}
// Usage
do {
let profile = try fetchUserProfile(userId: "user123")
displayProfile(profile)
} catch {
// Handle all possible errors here
handleError(error)
}
When to use this pattern:
- For sequential operations that depend on each other
- When intermediate functions don't need to handle the error
- To centralize error handling logic
Pattern 4: Error Transformation Pattern
This pattern involves catching errors at intermediate levels, then transforming them to provide more context:
func fetchUserProfile(userId: String) throws -> UserProfile {
do {
let userData = try fetchUserData(userId)
return try parseUserProfile(userData)
} catch let error as NetworkError {
// Transform the low-level error into a more meaningful domain error
throw UserProfileError.fetchFailed(userId: userId, underlying: error)
} catch {
throw UserProfileError.unexpected(underlying: error)
}
}
enum UserProfileError: Error {
case fetchFailed(userId: String, underlying: Error)
case unexpected(underlying: Error)
}
When to use this pattern:
- To add domain context to lower-level errors
- When you want to hide implementation details
- To create a clean error API boundary between subsystems
Error Handling Strategies
Pattern 5: Result Type Pattern
Swift's Result
type provides an elegant way to handle errors without throwing:
func fetchUserData(completion: @escaping (Result<UserData, NetworkError>) -> Void) {
// Perform network request
networkService.request("users/profile") { data, response, error in
if let error = error {
completion(.failure(.connectionFailed(underlying: error)))
return
}
guard let data = data else {
completion(.failure(.invalidData))
return
}
// Process data
do {
let userData = try JSONDecoder().decode(UserData.self, from: data)
completion(.success(userData))
} catch {
completion(.failure(.parseError(underlying: error)))
}
}
}
// Usage
fetchUserData { result in
switch result {
case .success(let userData):
updateUI(with: userData)
case .failure(let error):
handleError(error)
}
}
When to use this pattern:
- For asynchronous operations
- When you want to avoid try-catch blocks
- For APIs that need to return either a value or an error
Pattern 6: Optional Chaining with Early Returns
For simpler functions, especially UI code, you might prefer a more lightweight approach:
func processFormInput() -> Bool {
guard let name = nameTextField.text, !name.isEmpty else {
showError("Name is required")
return false
}
guard let email = emailTextField.text, email.contains("@") else {
showError("Valid email is required")
return false
}
// Process the valid input
submitForm(name: name, email: email)
return true
}
// Usage
@IBAction func submitButtonTapped() {
if processFormInput() {
showSuccessMessage()
}
}
When to use this pattern:
- For input validation
- When error cases are simple and limited
- For UI code where throwing errors would be cumbersome
Advanced Error Design Patterns
Pattern 7: Error Middleware Pattern
This pattern creates a pipeline for processing errors through a series of handlers:
typealias ErrorMiddleware = (Error) -> Error?
class ErrorHandler {
private var middleware: [ErrorMiddleware] = []
func addMiddleware(_ middleware: @escaping ErrorMiddleware) {
self.middleware.append(middleware)
}
func handleError(_ error: Error) -> Error {
var processedError = error
for handler in middleware {
if let newError = handler(processedError) {
processedError = newError
}
}
return processedError
}
}
// Example middleware
func loggingMiddleware(error: Error) -> Error? {
print("Error occurred: \(error)")
return nil // Return nil means no transformation
}
func networkRetryMiddleware(error: Error) -> Error? {
if let networkError = error as? NetworkError,
networkError == .connectionFailed {
// Retry the connection
return nil
}
return nil
}
When to use this pattern:
- For complex applications with many error types
- When you need consistent error handling across the app
- For implementing cross-cutting error handling like logging or analytics
Pattern 8: Recoverable Error Pattern
Some errors can be automatically recovered from without user intervention:
protocol RecoverableError: Error {
func attemptRecovery() -> Bool
}
extension NetworkError: RecoverableError {
func attemptRecovery() -> Bool {
switch self {
case .tokenExpired:
// Attempt to refresh the token
return TokenManager.shared.refreshToken()
case .connectionFailed:
// Retry the connection once
return NetworkService.shared.reconnect()
default:
return false
}
}
}
// Usage
do {
try performNetworkOperation()
} catch let error as RecoverableError {
if error.attemptRecovery() {
// Retry the operation
try? performNetworkOperation()
} else {
// Handle the error normally
handleError(error)
}
} catch {
handleError(error)
}
When to use this pattern:
- For errors that have automatic recovery strategies
- When you want to hide recovery complexity from calling code
- For improving user experience by reducing visible errors
Real-World Examples
Let's look at how these patterns apply in real-world scenarios:
Example 1: Network Request Layer
// Define domain-specific errors
enum NetworkError: Error {
case connectionFailed
case invalidResponse(statusCode: Int)
case decodingFailed
case invalidURL
}
enum UserAPIError: Error {
case userNotFound(userId: String)
case accessDenied
case serverError(underlying: Error)
}
// Network service using Result type pattern
class NetworkService {
func fetch<T: Decodable>(endpoint: String, completion: @escaping (Result<T, NetworkError>) -> Void) {
guard let url = URL(string: "https://api.example.com/" + endpoint) else {
completion(.failure(.invalidURL))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(.connectionFailed))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse(statusCode: 0)))
return
}
guard (200...299).contains(httpResponse.statusCode) else {
completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode)))
return
}
guard let data = data else {
completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode)))
return
}
do {
let decodedObject = try JSONDecoder().decode(T.self, from: data)
completion(.success(decodedObject))
} catch {
completion(.failure(.decodingFailed))
}
}.resume()
}
}
// User service implementing error transformation pattern
class UserService {
let networkService = NetworkService()
func fetchUser(id: String, completion: @escaping (Result<User, UserAPIError>) -> Void) {
networkService.fetch(endpoint: "users/\(id)") { (result: Result<User, NetworkError>) in
switch result {
case .success(let user):
completion(.success(user))
case .failure(let error):
switch error {
case .invalidResponse(let statusCode):
if statusCode == 404 {
completion(.failure(.userNotFound(userId: id)))
} else if statusCode == 403 {
completion(.failure(.accessDenied))
} else {
completion(.failure(.serverError(underlying: error)))
}
default:
completion(.failure(.serverError(underlying: error)))
}
}
}
}
}
// Usage in a view controller
class UserProfileViewController: UIViewController {
let userService = UserService()
func loadUserProfile(userId: String) {
userService.fetchUser(id: userId) { result in
DispatchQueue.main.async {
switch result {
case .success(let user):
self.updateUI(with: user)
case .failure(let error):
switch error {
case .userNotFound:
self.showError("User not found")
case .accessDenied:
self.showLoginScreen()
case .serverError:
self.showError("Something went wrong. Please try again.")
}
}
}
}
}
}
Example 2: Data Processing Pipeline
// Define error types for a data import process
enum ImportError: Error {
case fileNotFound(path: String)
case invalidFormat
case corruptData(line: Int)
case databaseError(description: String)
}
// Implement a multi-stage data processing pipeline
class DataImporter {
func importData(from filePath: String) throws {
do {
let data = try readFile(at: filePath)
let parsedRecords = try parseRecords(from: data)
let validRecords = try validateRecords(parsedRecords)
try saveRecords(validRecords)
print("Successfully imported \(validRecords.count) records")
} catch let error as ImportError {
// Log the specific error
logError(error)
// Rethrow so the caller can handle it
throw error
} catch {
// Handle unexpected errors
let wrappedError = ImportError.databaseError(description: "Unexpected error: \(error.localizedDescription)")
logError(wrappedError)
throw wrappedError
}
}
private func readFile(at path: String) throws -> Data {
guard FileManager.default.fileExists(atPath: path) else {
throw ImportError.fileNotFound(path: path)
}
return try Data(contentsOf: URL(fileURLWithPath: path))
}
private func parseRecords(from data: Data) throws -> [Record] {
guard let content = String(data: data, encoding: .utf8) else {
throw ImportError.invalidFormat
}
var records = [Record]()
let lines = content.components(separatedBy: .newlines)
for (index, line) in lines.enumerated() {
do {
let record = try Record(from: line)
records.append(record)
} catch {
throw ImportError.corruptData(line: index + 1)
}
}
return records
}
private func validateRecords(_ records: [Record]) throws -> [Record] {
// Validation logic
return records.filter { $0.isValid }
}
private func saveRecords(_ records: [Record]) throws {
// Database saving logic
try DatabaseService.shared.save(records)
}
private func logError(_ error: Error) {
// Log error to monitoring system
print("Import error: \(error)")
}
}
struct Record {
let id: String
let name: String
let value: Double
var isValid: Bool {
return !id.isEmpty && !name.isEmpty && value >= 0
}
init(from line: String) throws {
// Parsing logic here
let components = line.components(separatedBy: ",")
guard components.count == 3,
let value = Double(components[2]) else {
throw ImportError.invalidFormat
}
self.id = components[0]
self.name = components[1]
self.value = value
}
}
// Usage
let importer = DataImporter()
do {
try importer.importData(from: "/path/to/data.csv")
showSuccessMessage()
} catch ImportError.fileNotFound(let path) {
showError("File not found: \(path)")
} catch ImportError.invalidFormat {
showError("The file format is invalid.")
} catch ImportError.corruptData(let line) {
showError("Data is corrupt at line \(line)")
} catch {
showError("Import failed: \(error.localizedDescription)")
}
Best Practices for Error Design
-
Be Specific: Create domain-specific error types that clearly communicate what went wrong.
-
Include Context: Errors should provide enough context to understand the problem and possibly how to fix it.
-
Layer Your Errors: Low-level components should use detailed technical errors, while higher-level components should use more user-friendly errors.
-
Document Your Errors: Make sure to document what errors your functions can throw and under what conditions.
-
Be Consistent: Use similar error patterns throughout your codebase.
-
Localize Error Messages: For user-facing errors, ensure messages are localized.
-
Avoid Excessive Nesting: Avoid deeply nested do-catch blocks by extracting error-prone code into separate functions.
-
Consider the User Experience: Design errors with the end-user in mind, making them actionable when possible.
Summary
Swift error design patterns provide structured ways to handle errors in your applications. We've covered:
- Error type definitions using enums and structs
- Error propagation through bubbling up and transformation
- Handling strategies including Result type and optional chaining
- Advanced patterns like error middleware and recoverable errors
- Real-world examples demonstrating these patterns in practice
By applying these patterns appropriately, you can create more robust, maintainable code that handles errors gracefully and provides a better experience for your users.
Additional Resources
- Swift Documentation on Error Handling
- Swift Evolution Proposal: Result Type
- WWDC Session: Embracing Algorithms - Covers error handling patterns
Exercises
- Convert an existing function that returns an optional to use the
Result
type instead. - Create a domain-specific error type for a hypothetical banking application with at least 5 different error cases.
- Implement the Error Middleware pattern for a simple application that logs errors and attempts recovery.
- Refactor a complex function to use the Bubble-Up pattern to simplify its error handling.
- Design an error handling strategy for a multi-stage data processing pipeline.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)