Polyglot
Languages Swift error handling
Swift § error-handling

Error handling

Swift’s error-handling model is typed and explicit — errors are values conforming to the Error protocol; functions that may throw are marked throws; calling a throwing function requires try. The principal forms: do/catch/throw for exception-style flow; try? for “convert to optional”; try! for “trap on error” (force-unwrap analogue); Result<Success, Failure> for value-style error handling. The conventional discipline is throws for the principal mechanism — Swift’s typed errors admit substantial flexibility without runtime exception-unwinding cost. The combination — typed errors via the Error protocol, the throws modifier, try/do/catch, the Result value type, the defer for cleanup — is the substance of Swift’s error-handling surface.

The Error protocol

The Error protocol marks types as errors:

enum ParseError: Error {
    case empty
    case invalid(reason: String)
    case tooLong(count: Int)
}

struct NetworkError: Error {
    let code: Int
    let message: String
}

Any type may conform; enums are conventional for distinct error variants, structs for substantial error data.

throws and throw

A function that may throw is marked throws:

enum ParseError: Error {
    case empty
    case invalid(reason: String)
}

func parse(_ input: String) throws -> Int {
    guard !input.isEmpty else {
        throw ParseError.empty
    }
    guard let n = Int(input) else {
        throw ParseError.invalid(reason: "not numeric")
    }
    return n
}

Calling a throwing function requires try:

do {
    let n = try parse("42")
    print(n)
} catch {
    print("error: \(error)")
}

do/catch

The conventional handler form:

do {
    let n = try parse(input)
    let doubled = try compute(n)
    print(doubled)
} catch ParseError.empty {
    print("input was empty")
} catch ParseError.invalid(let reason) {
    print("invalid: \(reason)")
} catch {
    print("other: \(error)")
}

Multiple catch clauses admit type-based dispatch via pattern matching — the same pattern grammar as switch.

The error is the implicit binding in a bare catch:

do {
    try riskyOperation()
} catch {
    print("caught: \(error)")
}

Equivalent to:

do {
    try riskyOperation()
} catch let error {
    print("caught: \(error)")
}

try?

The try? admits “convert throw to nil”:

let n = try? parse("42")                           // Optional<Int>; nil on error
let n = try? parse("abc")                          // nil

if let n = try? parse(input) {
    process(n)
}

The conventional uses are when error details are not needed; loses substantial error information.

try!

The try! admits “trap on error”:

let n = try! parse("42")                           // Int (crashes on error)
let url = try! URL.parse("https://example.com")   // when failure is impossible

The conventional discipline avoids try! — admit only when the error cannot reasonably occur. The crash on error is runtime trap; analogous to force-unwrap (!) on optionals.

Result type

The Result<Success, Failure> admits value-style error handling:

enum FetchError: Error {
    case network(URLError)
    case decoding(DecodingError)
    case timeout
}

func fetch() async -> Result<Data, FetchError> {
    do {
        let (data, _) = try await URLSession.shared.data(from: url)
        return .success(data)
    } catch let error as URLError {
        return .failure(.network(error))
    } catch {
        return .failure(.timeout)
    }
}

let result = await fetch()
switch result {
case .success(let data):
    process(data)
case .failure(.network(let urlError)):
    handle(urlError)
case .failure(.timeout):
    retry()
default:
    fallback()
}

The Result is conventional in callback-based APIs and substantial composition; for async/await, throwing is typically conventional.

Result.get()

Convert a Result to a throwing call:

let result: Result<Int, MyError> = .success(42)

do {
    let value = try result.get()                   // unwraps success or throws
    print(value)
} catch {
    print(error)
}

Conversion between throws and Result

// Throws to Result:
let result = Result { try parse(input) }

// Result to throws:
let value = try result.get()

The mechanisms admit substantial composition between the two paradigms.

defer

The defer admits deferred execution at scope exit:

func processFile(path: String) throws -> String {
    let file = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { file.closeFile() }                     // runs at scope exit, even on throw

    return try String(contentsOf: file)
}

The defer is the conventional Swift form for cleanup; admits substantial linear code paths with explicit cleanup.

Multiple defers run in reverse order (LIFO):

func example() {
    defer { print("first deferred") }
    defer { print("second deferred") }
    defer { print("third deferred") }
    print("end")
}

// Output:
// end
// third deferred
// second deferred
// first deferred

rethrows

A function that re-throws errors from a closure parameter:

func map<T, U>(_ array: [T], transform: (T) throws -> U) rethrows -> [U] {
    var result: [U] = []
    for item in array {
        result.append(try transform(item))         // rethrows the closure's error
    }
    return result
}

// Calling with a throwing closure requires try:
do {
    let parsed = try map(strings) { try parse($0) }
} catch { /* ... */ }

// Calling with a non-throwing closure does not:
let doubled = map([1, 2, 3]) { $0 * 2 }            // no try needed

The rethrows admits the function being throwing or non-throwing depending on the closure’s behaviour.

Custom Error types

Enum errors

enum AuthError: Error {
    case notAuthenticated
    case insufficientPermissions(required: [Permission])
    case sessionExpired(at: Date)

    var localizedDescription: String {
        switch self {
        case .notAuthenticated:
            return "User is not authenticated"
        case .insufficientPermissions(let required):
            return "Missing permissions: \(required)"
        case .sessionExpired(let date):
            return "Session expired at \(date)"
        }
    }
}

Struct errors

struct ValidationError: Error {
    let field: String
    let message: String
    let underlying: Error?

    init(field: String, message: String, underlying: Error? = nil) {
        self.field = field
        self.message = message
        self.underlying = underlying
    }
}

Conforming to LocalizedError

For substantial human-readable messages:

enum AuthError: LocalizedError {
    case notAuthenticated
    case insufficientPermissions

    var errorDescription: String? {
        switch self {
        case .notAuthenticated:
            return "You must log in"
        case .insufficientPermissions:
            return "You don't have permission"
        }
    }

    var failureReason: String? {
        switch self {
        case .notAuthenticated:
            return "No active session"
        case .insufficientPermissions:
            return "Account lacks required role"
        }
    }
}

The LocalizedError admits substantial integration with system localisation.

Async errors

async functions integrate seamlessly with throws:

func fetch(_ url: URL) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse else {
        throw FetchError.invalidResponse
    }
    guard (200..<300).contains(httpResponse.statusCode) else {
        throw FetchError.statusCode(httpResponse.statusCode)
    }
    return data
}

// Calling:
do {
    let data = try await fetch(url)
    process(data)
} catch FetchError.statusCode(404) {
    notFound()
} catch {
    log(error)
}

Treated in Concurrency.

Common patterns

Validate-and-throw

func validate(user: User) throws {
    guard !user.name.isEmpty else {
        throw ValidationError.emptyName
    }
    guard user.age >= 0 && user.age <= 150 else {
        throw ValidationError.invalidAge(user.age)
    }
    guard user.email.contains("@") else {
        throw ValidationError.invalidEmail(user.email)
    }
}

Wrap external errors

func loadConfig() throws -> Config {
    do {
        let data = try Data(contentsOf: configURL)
        return try JSONDecoder().decode(Config.self, from: data)
    } catch let error as DecodingError {
        throw ConfigError.invalidFormat(underlying: error)
    } catch {
        throw ConfigError.loadFailed(underlying: error)
    }
}

Try-with-default

func fetchOrDefault() -> Data {
    return (try? fetch()) ?? Data()
}

// Or:
func fetchOrDefault() -> Data {
    do {
        return try fetch()
    } catch {
        log(error)
        return Data()
    }
}

Resource cleanup with defer

func processFile(at path: String) throws -> ProcessResult {
    let file = try open(path)
    defer { close(file) }                          // always runs

    let lock = try acquireLock(file)
    defer { releaseLock(lock) }                    // always runs

    return try process(file)
}

Retry with substantial logic

func with<T>(retries: Int, _ operation: () async throws -> T) async throws -> T {
    var lastError: Error?
    for attempt in 0..<retries {
        do {
            return try await operation()
        } catch let error as RetryableError {
            lastError = error
            if attempt < retries - 1 {
                try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
            }
        }
    }
    throw lastError!
}

Result for callback APIs

func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data else {
            completion(.failure(FetchError.noData))
            return
        }
        completion(.success(data))
    }.resume()
}

In modern Swift, async/await is conventionally preferred over callback-based APIs.

Type-based catch chains

do {
    try operation()
} catch let error as NetworkError {
    handleNetwork(error)
} catch let error as ValidationError {
    handleValidation(error)
} catch let error as DecodingError {
    handleDecoding(error)
} catch {
    log("unexpected: \(error)")
}

Pattern matching in catch

do {
    try fetch()
} catch FetchError.statusCode(let code) where (400..<500).contains(code) {
    handleClientError(code)
} catch FetchError.statusCode(let code) where (500..<600).contains(code) {
    handleServerError(code)
} catch {
    log(error)
}

The catch admits the substantial pattern grammar.

Throwing function as parameter

extension Sequence {
    func firstResult<T>(_ transform: (Element) throws -> T) rethrows -> T? {
        for item in self {
            return try transform(item)
        }
        return nil
    }
}

let result = try [1, 2, 3].firstResult { try parse(String($0)) }

The rethrows admits substantial flexibility.

Multiple errors with TaskGroup

func fetchAll(urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { try await fetch(url) }
        }

        var results: [Data] = []
        for try await data in group {              // throws on first failure
            results.append(data)
        }
        return results
    }
}

Treated in Concurrency.

precondition and assert

For programmer-error checks (not user-facing errors):

func divide(_ a: Int, _ b: Int) -> Int {
    precondition(b != 0, "division by zero")       // runtime check
    return a / b
}

func processIndex(_ i: Int, in arr: [Int]) -> Int {
    assert(i < arr.count)                          // debug-only check
    return arr[i]
}

The precondition runs in all builds; assert runs only in debug.

For unrecoverable errors:

func nextID() -> ID {
    guard let id = generateID() else {
        fatalError("ID generation failed")          // crashes; runs in all builds
    }
    return id
}

Error wrapping with cause

struct WrappedError: Error {
    let context: String
    let underlying: Error
}

func loadConfig() throws -> Config {
    do {
        let data = try Data(contentsOf: configURL)
        return try JSONDecoder().decode(Config.self, from: data)
    } catch {
        throw WrappedError(context: "loading config from \(configURL)", underlying: error)
    }
}

try? for “best effort”

func loadCachedOrFetch() async -> Data {
    if let cached = try? loadCache() {
        return cached
    }
    return (try? await fetch()) ?? Data()
}

Throwing initialiser

struct User: Codable {
    let id: Int
    let name: String

    init(from data: Data) throws {
        self = try JSONDecoder().decode(User.self, from: data)
    }
}

let user = try User(from: jsonData)

The init throws admits substantial conventional construction with substantial error reporting.

A note on Result vs throws

The two complement each other:

ApproachUse when
throwsSynchronous code with substantial composition
async throwsAsynchronous code
Result<S, F>Callback-based APIs; storing for later handling

Generally, throws is the conventional Swift form; Result is conventional in callback patterns and when the error must be stored or composed.

A note on the conventional discipline

The contemporary Swift error-handling advice:

  • Conform error types to Error — typically enums for distinct variants.
  • Use throws for fallible synchronous functions.
  • Use async throws for fallible asynchronous functions.
  • Use do/catch with type-based dispatch.
  • Use try? when error details are not needed.
  • Avoid try! — admit only when failure is impossible.
  • Use Result for callback patterns and substantial composition.
  • Use defer for resource cleanup.
  • Use rethrows for higher-order functions taking throwing closures.
  • Use LocalizedError for user-facing error messages.
  • Use precondition/assert/fatalError for programmer errors (not user errors).
  • Wrap external errors with substantial context.

The combination — the Error protocol, the throws modifier, try/do/catch, the Result value type, defer for cleanup, rethrows for higher-order functions — is the substance of Swift’s error-handling surface. The discipline produces explicit, type-safe, well-documented error flows with substantial flexibility for substantial error-handling patterns.