Skip to main content

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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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​

swift
// 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​

swift
// 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​

  1. Be Specific: Create domain-specific error types that clearly communicate what went wrong.

  2. Include Context: Errors should provide enough context to understand the problem and possibly how to fix it.

  3. Layer Your Errors: Low-level components should use detailed technical errors, while higher-level components should use more user-friendly errors.

  4. Document Your Errors: Make sure to document what errors your functions can throw and under what conditions.

  5. Be Consistent: Use similar error patterns throughout your codebase.

  6. Localize Error Messages: For user-facing errors, ensure messages are localized.

  7. Avoid Excessive Nesting: Avoid deeply nested do-catch blocks by extracting error-prone code into separate functions.

  8. 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​

Exercises​

  1. Convert an existing function that returns an optional to use the Result type instead.
  2. Create a domain-specific error type for a hypothetical banking application with at least 5 different error cases.
  3. Implement the Error Middleware pattern for a simple application that logs errors and attempts recovery.
  4. Refactor a complex function to use the Bubble-Up pattern to simplify its error handling.
  5. 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 feedback@compilenrun.com. I’d greatly appreciate your feedback! :)