Skip to content
intermediate Phase 1 · Swift Foundations

Error Handling

Use try/catch, throws, Result type, and custom error types for robust error management.

40m
3 problems
Topic Progress 0%

Error Protocol & Custom Errors

Error Protocol & Custom Errors

Swift uses the Error protocol to represent errors that can occur at runtime. Any type can conform to Error—typically an enum.

The Error Protocol

The Error protocol is empty—it serves as a marker:

public protocol Error : Sendable { }

Any type conforming to Error can be thrown and caught.

Defining Custom Errors

The most common pattern is to use enums with associated values:

enum NetworkError: Error {
    case invalidURL
    case noConnection
    case serverError(statusCode: Int)
    case decodingFailed(underlying: Error)
}

Enums are ideal because each case can carry different associated data, providing rich error information.

Simple Errors

For cases without associated values:

enum ValidationError: Error {
    case emptyField
    case invalidEmail
    case passwordTooShort
    case ageOutOfRange
}

Error Hierarchies

For complex domains, create error hierarchies:

enum DatabaseError: Error {
    case connectionFailed(underlying: Error)
    case queryFailed(sql: String, reason: String)
    case recordNotFound(id: String)
    case constraintViolation(field: String)
}

enum APIError: Error {
    case unauthorized
    case forbidden
    case notFound
    case serverError(status: Int, message: String?)
    case networkError(underlying: Error)
}

LocalizedError

For user-facing error messages, conform to LocalizedError:

enum AppError: Error, LocalizedError {
    case fileNotFound(name: String)
    case permissionDenied
    
    var errorDescription: String? {
        switch self {
        case .fileNotFound(let name):
            return "File '\(name)' was not found."
        case .permissionDenied:
            return "You don't have permission to access this."
        }
    }
}

print(AppError.fileNotFound(name: "config.json").localizedDescription)
// "File 'config.json' was not found."

Custom Error Properties

You can add computed properties to errors:

enum APIError: Error {
    case rateLimited(retryAfter: TimeInterval)
    case serverError(code: Int)
    
    var shouldRetry: Bool {
        switch self {
        case .rateLimited: return true
        case .serverError(let code): return code >= 500
        }
    }
}

Equatable and Hashable Errors

For testing and comparison:

enum TestError: Error, Equatable {
    case caseA
    case caseB
}

// Automatically Equatable because all cases have no associated values

When associated values are present, you need to manually conform or use the auto-synthesis in Swift 5.1+.

try/catch/throw

try/catch/throw

Throwing Errors

Use throw to signal an error:

func divide(_ a: Int, by b: Int) throws -> Int {
    guard b != 0 else {
        throw MathError.divisionByZero
    }
    return a / b
}

enum MathError: Error {
    case divisionByZero
}

Any function that can throw must be marked with throws in its signature.

Catching Errors

Use do/try/catch to handle errors:

do {
    let result = try divide(10, by: 0)
    print(result)
} catch MathError.divisionByZero {
    print("Cannot divide by zero!")
} catch {
    print("Unknown error: \(error)")
}

Multiple Catch Blocks

do {
    let data = try fetchData()
    let parsed = try parse(data)
} catch NetworkError.noConnection {
    print("No internet")
} catch NetworkError.serverError(let code) {
    print("Server error: \(code)")
} catch {
    print("Other error: \(error)")
}

The error implicit variable holds the thrown error in the default catch block.

Propagating Errors

Functions can propagate errors to their caller without handling them:

func processFile(named name: String) throws {
    let data = try readFile(name)  // throws propagate
    let parsed = try parse(data)   // throws propagate
    save(parsed)
}

try? (Optional Try)

Converts errors to optionals—nil if an error occurs:

let result = try? divide(10, by: 0)  // nil
let valid = try? divide(10, by: 2)   // Optional(5)

This is useful when the error doesn't matter and you just need the value or nil.

try! (Force Try)

Crashes if an error is thrown—use only when you're certain no error will occur:

let config = try! JSONDecoder().decode(Config.self, from: data)  // crashes on error

Rethrowing Functions

Functions marked rethrows only throw if their closure argument throws:

func process<T>(_ items: [T], transform: (T) throws -> T) rethrows -> [T] {
    var result: [T] = []
    for item in items {
        result.append(try transform(item))
    }
    return result
}

// No try needed if transform doesn't throw
let doubled = process([1, 2, 3]) { $0 * 2 }

Practical Example

func loadUser(from json: String) throws -> User {
    guard let data = json.data(using: .utf8) else {
        throw UserError.invalidData
    }
    
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    
    do {
        return try decoder.decode(User.self, from: data)
    } catch {
        throw UserError.decodingFailed(underlying: error)
    }
}

enum UserError: Error {
    case invalidData
    case decodingFailed(underlying: Error)
}

The do/try/catch pattern is Swift's primary error handling mechanism. It makes error paths explicit and forces you to handle errors at compile time.

Result Type

Result Type

Result<Success, Failure> is a generic enum that represents either a success or a failure. It provides an alternative to throwing errors, especially useful in asynchronous code and APIs where you want explicit success/failure handling.

Result Definition

enum Result<Success, Failure: Error> {
    case success(Success)
    case failure(Failure)
}

Creating Results

func divide(_ a: Int, by b: Int) -> Result<Int, MathError> {
    guard b != 0 else {
        return .failure(.divisionByZero)
    }
    return .success(a / b)
}

enum MathError: Error {
    case divisionByZero
}

let result = divide(10, by: 2)
// .success(5)

Handling Results

switch result {
case .success(let value):
    print("Result: \(value)")
case .failure(let error):
    print("Error: \(error)")
}

Using map on Result

let doubled = divide(10, by: 2).map { $0 * 2 }  // .success(20)

Converting Throws to Result

let result = Result { try divide(10, by: 0) }  // .failure(.divisionByZero)

Result with Try

if case .success(let value) = result {
    print(value)
}

// Or using value property (deprecated in newer Swift)
// let value = result.value

Practical Example: Network Layer

enum APIError: Error {
    case invalidURL
    case noData
    case decodingFailed(Error)
}

func fetchUser(id: String, completion: @escaping (Result<User, APIError>) -> Void) {
    guard let url = URL(string: "https://api.example.com/users/\(id)") else {
        completion(.failure(.invalidURL))
        return
    }
    
    URLSession.shared.dataTask(with: url) { data, _, error in
        if let error = error {
            completion(.failure(.decodingFailed(error)))
            return
        }
        guard let data = data else {
            completion(.failure(.noData))
            return
        }
        
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(.decodingFailed(error)))
        }
    }.resume()
}

Result vs Throwing Functions

Feature Throwing Result
Syntax try/catch Pattern matching
Async compatibility Limited (pre-concurrency) Excellent
Composability do/try/catch nesting map, flatMap chaining
Explicit error type No (any Error) Yes (typed)

flatMap on Result

let result = divide(10, by: 2)
    .flatMap { divide($0, by: 3) }
// .success(1) (5 / 3 = 1)

Result type provides typed, composable error handling that works well with closures and async patterns.

Quiz

1. What protocol must error types conform to?

Question 1 options

2. What does `try?` do?

Question 2 options

3. What does Result.failure contain?

Question 3 options

4. When should you use `try!`?

Question 4 options

5. What makes a function `rethrows`?

Question 5 options

Flashcards

Question

How do you define a custom error in Swift?

Answer

Create an enum conforming to `Error`, with cases for each error type: `enum MyError: Error { case foo }`

Question

What is the difference between `try?` and `try!`?

Answer

`try?` returns Optional (nil on error); `try!` crashes on error.

Question

What does Result<Success, Failure> represent?

Answer

Either .success(Success) or .failure(Failure), where Failure must conform to Error.

Question

How do you propagate errors without catching them?

Answer

Mark the function with `throws` and the errors automatically propagate to the caller.

Question

What is LocalizedError used for?

Answer

Providing user-friendly error descriptions via `errorDescription` computed property.

Revision Notes

Key Takeaways

  • 1. Custom errors are typically enums conforming to Error
  • 2. do/try/catch is Swift's primary error handling mechanism
  • 3. try? converts errors to optionals; try! crashes on error
  • 4. Result type provides typed success/failure handling
  • 5. LocalizedError enables user-friendly error messages

Interview Tips

  • Explain when to use Result vs throwing functions
  • Know the difference between try?, try!, and regular try
  • Be able to design a custom error hierarchy
  • Understand rethrows and when to use it

Cheat Sheet

Error Handling Cheat Sheet

Defining Errors:

enum MyError: Error { case foo, bar }

Throwing:

func doWork() throws { throw MyError.foo }

Catching:

do { try doWork() } catch MyError.foo { ... } catch { ... }

Shorthand:

  • try? — nil on error
  • try! — crashes on error

Result Type:

Result<Int, Error>  // .success(42) or .failure(error)

Rethrows:

func process(_ f: () throws -> Void) rethrows { try f() }