Elegant error handling for Swift - Transform verbose try/catch blocks into clean, functional code with powerful error operators.
Before: Traditional Swift Error Handling
// Verbose, nested, hard to follow
func processUserData() throws -> UserProfile {
do {
let userData = try fetchUserData()
do {
let profile = try parseUserData(userData)
return profile
} catch {
print("Parse error: \(error)")
throw error
}
} catch {
print("Fetch error: \(error)")
throw error
}
}
After: With swift-err
// Clean, flat, easy to follow
func processUserData() throws -> UserProfile {
var err: Error = .Empty()
guard let userData = try fetchUserData() !> err else {
print("Fetch error: \(err)")
throw err
}
guard let profile = try parseUserData(userData) !> err else {
print("Parse error: \(err)")
throw err
}
return profile
}
- Overview
- Installation
- The Problem
- The Solution
- Usage
- Functional Error Handling
- Advanced Features
- Why Use swift-err?
- Real World Examples
- Requirements
- Coming Soon
- License
- Contributing
Swift's traditional error handling with try
/catch
becomes verbose and unwieldy, especially when dealing with multiple error-throwing operations. swift-err
provides a concise, functional approach to error handling that lets you:
- Handle errors with minimal boilerplate
- Differentiate between error types cleanly
- Convert try/throw functions into Result types elegantly
- Maintain certainty about your error state and values
Think of it as bringing the best parts of Go's error handling to Swift, but with Swift's type safety and expressiveness.
Add swift-err
as a dependency in your Package.swift
:
dependencies: [
.package(url: "https://github.com/walteh/swift-err.git", from: "*latest-release*")
]
targets: [
.target(
name: "YourTarget",
dependencies: ["Err"]
)
]
Swift's traditional error handling becomes problematic when you have multiple throwing operations:
// Traditional Swift error handling - verbose with multiple do-catch blocks
func processUserData(userId: String) throws -> UserProfile {
// Approach 1: Nested do-catch blocks - gets deeply nested
do {
let userData = try fetchUserData(userId: userId)
do {
let profile = try parseUserData(data: userData)
do {
return try validateUserProfile(profile: profile)
} catch {
throw error
}
} catch {
throw error
}
} catch {
throw error
}
}
// Approach 2: Sequential do-catch blocks - verbose and repetitive
func processUserData(userId: String) throws -> UserProfile {
let userData: Data
do {
userData = try fetchUserData(userId: userId)
} catch {
throw error
}
let profile: UserProfile
do {
profile = try parseUserData(data: userData)
} catch {
throw error
}
do {
return try validateUserProfile(profile: profile)
} catch {
throw error
}
}
// Approach 3: Error type differentiation - requires manual pattern matching
func processUserData(userId: String) throws -> UserProfile {
do {
let userData = try fetchUserData(userId: userId)
let profile = try parseUserData(data: userData)
return try validateUserProfile(profile: profile)
} catch let error as NetworkError {
switch error {
case .connectionFailed:
// Handle connection failure
case .timeout:
// Handle timeout
default:
// Handle other network errors
}
throw error
} catch let error as ParsingError {
// Handle parsing errors
throw error
} catch {
// Handle other errors
throw error
}
}
All these approaches are verbose, repetitive, and make your code harder to read and maintain.
swift-err
solves this with its error operators:
func processUserData(userId: String) throws -> UserProfile {
var err: Error = .Empty()
guard let userData = try fetchUserData(userId: userId) !> err else {
// Here we can handle specific error types
if let networkError = err as? NetworkError {
switch networkError {
case .connectionFailed:
print("Connection failed, will retry")
// Maybe retry or take specific action
default:
print("Network error occurred: \(networkError)")
}
} else {
print("Unknown error: \(err)")
}
throw err
}
guard let profile = try parseUserData(data: userData) !> err else {
// We know exactly which operation failed
throw err
}
guard let validatedProfile = try validateUserProfile(profile: profile) !> err else {
// We know exactly which operation failed
throw err
}
return validatedProfile
}
This approach is:
- Concise and readable
- Allows for specific error handling at each step
- Maintains a clear flow with guard statements
- Gives certainty about your values after each guard
The !>
operator captures errors in guard statements, giving you certainty about your error state:
func processData() throws -> String {
var err: Error = .Empty() // Initialize with a default error
guard let result = try someThrowingFunction() !> err else {
// Here, we KNOW that err contains the actual error from someThrowingFunction
// We can handle specific error types
if let urlError = err as? URLError {
if urlError.code == .notConnectedToInternet {
// Handle specific network error
}
}
throw err
}
// Here, we KNOW that result is valid and no error occurred
return result
}
Note
Error.Empty()
is provided by the Err library to help with non-nullable error initialization.
For async functions, use the !>>
operator:
func fetchUserData() async throws -> Data {
var err: Error = .Empty()
// Notice the parentheses around the async expression
guard let (data, _) = await (try await URLSession.shared.data(from: url)) !>> err else {
// Here, we KNOW that err contains the network error
if let urlError = err as? URLError {
if urlError.code == .notConnectedToInternet {
// Handle specific network error
}
}
throw err
}
// Here, we KNOW that data is valid and no error occurred
return data
}
The operators seamlessly integrate with Swift's Result type:
func handleResult() throws -> Data {
var err: Error = .Empty()
let result: Result<Data, Error> = .success(Data())
guard let data = result !> err else {
throw err
}
return data
}
One of the most powerful patterns with swift-err
is converting traditional try/throw code into Result-based functions:
// Traditional approach with try/throw
func traditionalParse(json: String) throws -> User {
let data = json.data(using: .utf8)!
return try JSONDecoder().decode(User.self, from: data)
}
// Functional approach with Result
func functionalParse(json: String) -> Result<User, Error> {
Result {
let data = json.data(using: .utf8)!
return try JSONDecoder().decode(User.self, from: data)
}
}
// Usage with swift-err - clean error handling
func process(json: String) throws -> User {
var err: Error = .Empty()
guard let user = functionalParse(json: json) !> err else {
// We can handle specific error types
if let decodingError = err as? DecodingError {
switch decodingError {
case .keyNotFound(let key, _):
print("Missing key: \(key)")
case .valueNotFound(let type, _):
print("Missing value for type: \(type)")
default:
print("Other decoding error")
}
} else {
print("Unknown error: \(err)")
}
throw err
}
return user
}
swift-err
enables a more functional approach to error handling:
// Define functions that return Results
func fetchData(userId: String) -> Result<Data, Error> {
// Implementation
}
func parseData(data: Data) -> Result<ParsedData, Error> {
// Implementation
}
func validateData(parsed: ParsedData) -> Result<ValidatedData, Error> {
// Implementation
}
// Compose them together with clean error handling for each step
func processUser(userId: String) throws -> ValidatedData {
var err: Error = .Empty()
guard let data = fetchData(userId: userId) !> err else {
// Handle specific fetch errors
throw err
}
guard let parsed = parseData(data: data) !> err else {
// Handle specific parse errors
throw err
}
guard let validated = validateData(parsed: parsed) !> err else {
// Handle specific validation errors
throw err
}
return validated
}
This approach gives you:
- Clear separation of concerns
- Explicit error handling at each step
- A functional programming style with Result types
- Certainty about your values after each guard
swift-err
provides a way to add context to your errors using .ctx
:
func processUser() async throws -> User {
var err: Error = .Empty()
guard let data = try parseUserData() !> .ctx(&err, "Failed to parse user data") else {
// err is automatically wrapped with context information
// The original error is preserved as the "cause" of the context error
throw err
}
return data
}
When using context, you can inspect the original error using the cause(as:)
method:
func handleNetworkRequest() async throws -> Data {
var err: Error = .Empty()
guard let (data, _) = await (try await URLSession.shared.data(from: url)) !>> .ctx(&err, "Failed to fetch data") else {
// Check for specific error types in the cause chain
if let networkError = err.cause(as: URLError.self) {
switch networkError.code {
case .notConnectedToInternet:
print("No internet connection")
case .timedOut:
print("Request timed out")
default:
print("Other network error: \(networkError)")
}
}
throw err
}
return data
}
With context errors, you can inspect the error chain:
// Check if an error contains a specific error type in its chain
if let networkError = err.cause(as: URLError.self) {
// Handle network error
print("Network error: \(networkError)")
}
// Print the full error chain
if let chainedError = error as? ErrorWithCause {
for err in chainedError.causeErrorList() {
print(err)
}
}
- Reduced Verbosity: Eliminate nested do-catch blocks and repetitive error handling code
- Error Type Differentiation: Easily handle different error types with standard Swift type casting
- Functional Approach: Convert between try/throw and Result types elegantly
- Certainty: After a guard statement, you KNOW whether you have a valid result or an error
- Clean Syntax: The
!>
and!>>
operators provide a clean, Swift-like syntax - Async Support: First-class support for async/await with proper error handling
- Error Isolation: Handle errors at the function level where they occur
- Advanced Context: Optional context and error chaining for more sophisticated error handling
func fetchUserProfile(userId: String) async throws -> CompleteUserProfile {
var err: Error = .Empty()
// Fetch basic user data
guard let userData = await (try await fetchUserData(userId: userId)) !>> err else {
// Handle specific network errors
if let urlError = err as? URLError {
switch urlError.code {
case .notConnectedToInternet:
print("No internet connection")
case .timedOut:
print("Request timed out")
default:
print("Other URL error: \(urlError)")
}
}
throw err
}
// Fetch user preferences
guard let preferences = await (try await fetchUserPreferences(userId: userId)) !>> err else {
// We know exactly which operation failed
throw err
}
// Fetch user activity history
guard let activityHistory = await (try await fetchUserActivity(userId: userId)) !>> err else {
// We know exactly which operation failed
throw err
}
// Combine all data
return CompleteUserProfile(
userData: userData,
preferences: preferences,
activityHistory: activityHistory
)
}
// Original library functions that use try/throw
func libraryFetchData() throws -> Data { /* ... */ }
func libraryParseData(_ data: Data) throws -> ParsedData { /* ... */ }
func libraryProcessData(_ parsed: ParsedData) throws -> ProcessedData { /* ... */ }
// Functional wrappers
func fetchData() -> Result<Data, Error> {
Result { try libraryFetchData() }
}
func parseData(_ data: Data) -> Result<ParsedData, Error> {
Result { try libraryParseData(data) }
}
func processData(_ parsed: ParsedData) -> Result<ProcessedData, Error> {
Result { try libraryProcessData(parsed) }
}
// Usage with swift-err - clean error handling
func performOperation() throws -> ProcessedData {
var err: Error = .Empty()
guard let data = fetchData() !> err else {
// Handle fetch errors
throw err
}
guard let parsed = parseData(data) !> err else {
// Handle parse errors
throw err
}
guard let processed = processData(parsed) !> err else {
// Handle process errors
throw err
}
return processed
}
- Swift 6.0+
- macOS 13.0+ / iOS 13.0+ / tvOS 13.0+ / watchOS 6.0+ / macCatalyst 13.0+
The following components are currently in development:
- LogEvent: A structured logging event system that integrates with swift-log
- LogDistributor: A logging distribution system for routing log events to different destinations
This project is licensed under the Apache License 2.0.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add some amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request