BS
BleepingSwift
Published on
9 min read
Intermediate

> Error Handling in Swift: From Basics to Typed Throws

Share:

// What_You_Will_Learn

  • Use throw/try/catch and understand try? vs try!
  • Create custom error types with LocalizedError
  • Handle errors in async/await and task groups
  • Apply typed throws from Swift 6 for compile-time safety

Swift's error handling system is built around a few simple concepts: functions declare that they can fail with throws, callers handle failures with do-try-catch, and error types carry information about what went wrong. It sounds straightforward, but there's real depth here once you start building production apps.

The Basics

Any function that can fail is marked with throws:

Swift
enum ValidationError: Error {
    case tooShort
    case invalidCharacter(Character)
}

func validate(username: String) throws -> String {
    guard username.count >= 4 else {
        throw ValidationError.tooShort
    }

    for char in username {
        guard char.isLetter || char.isNumber else {
            throw ValidationError.invalidCharacter(char)
        }
    }

    return username
}

Callers use do-try-catch to handle errors:

Swift
do {
    let name = try validate(username: input)
    createAccount(with: name)
} catch ValidationError.tooShort {
    showError("Username must be at least 4 characters")
} catch ValidationError.invalidCharacter(let char) {
    showError("Username can't contain '\(char)'")
} catch {
    showError("Something went wrong: \(error.localizedDescription)")
}

Swift evaluates catch clauses top to bottom, so put specific catches before general ones. The final catch without a pattern acts as a catch-all, and the implicit error variable gives you access to whatever was thrown.

The Three Flavors of try

try requires a do-catch block. try? converts the result to an optional, returning nil on failure:

Swift
let name = try? validate(username: input)
// name is String? -- nil if validation failed

This is useful when you genuinely don't care why something failed. But overusing try? is an anti-pattern because it hides the reason for failure. If a network request fails, knowing whether it was a timeout vs. an authentication error changes how you respond.

try! force-unwraps the result and crashes if an error is thrown:

Swift
let data = try! Data(contentsOf: Bundle.main.url(forResource: "config", withExtension: "json")!)

Reserve try! for situations where failure genuinely represents a programming error, like loading a bundled resource that must exist for the app to function.

Custom Error Types

The Error protocol has no requirements, so any type can conform. Enums are the natural fit because they represent a finite set of failure cases:

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

Associated values let you attach context to each error case. The underlying parameter in decodingFailed preserves the original error for debugging while presenting a clean domain error to callers.

LocalizedError for User-Facing Messages

Conforming to LocalizedError gives your errors human-readable descriptions:

Swift
extension NetworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .noConnection:
            return "No internet connection. Check your network settings."
        case .timeout:
            return "The request timed out. Please try again."
        case .serverError(let code):
            return "Server returned an error (code \(code))."
        case .decodingFailed:
            return "The response couldn't be read."
        }
    }
}

Without LocalizedError, calling error.localizedDescription returns a generic message. With it, your errors produce specific, actionable text you can show directly in alerts or error views.

The Result Type

Result<Success, Failure> wraps either a success value or an error into a single type:

Swift
func fetchUser(id: Int) -> Result<User, NetworkError> {
    guard isConnected else {
        return .failure(.noConnection)
    }

    do {
        let user = try decoder.decode(User.self, from: data)
        return .success(user)
    } catch {
        return .failure(.decodingFailed(underlying: error))
    }
}

You can switch over the result or use .get() to convert it back into a throwing expression:

Swift
switch fetchUser(id: 42) {
case .success(let user):
    display(user)
case .failure(let error):
    showError(error.localizedDescription)
}

// Or convert to try/catch
do {
    let user = try fetchUser(id: 42).get()
    display(user)
} catch {
    showError(error.localizedDescription)
}

Since async/await arrived, Result has become less common for network calls because async throws handles that pattern more naturally. But Result still shines for representing stored outcomes, like a cached computation or a state enum:

Swift
enum LoadState<T> {
    case idle
    case loading
    case loaded(Result<T, Error>)
}

Error Handling in Async Code

Async functions combine async and throws naturally:

Swift
func fetchPosts(for user: User) async throws -> [Post] {
    let url = URL(string: "https://api.example.com/users/\(user.id)/posts")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.serverError(statusCode: 0)
    }

    guard http.statusCode == 200 else {
        throw NetworkError.serverError(statusCode: http.statusCode)
    }

    return try JSONDecoder().decode([Post].self, from: data)
}

When running concurrent work with task groups, you have two strategies. withThrowingTaskGroup fails fast, cancelling remaining tasks when the first error occurs:

Swift
func fetchAllData() async throws -> DashboardData {
    try await withThrowingTaskGroup(of: Sendable.self) { group in
        var user: User?
        var posts: [Post] = []

        group.addTask { try await self.fetchUser() }
        group.addTask { try await self.fetchPosts() }

        for try await result in group {
            if let u = result as? User { user = u }
            if let p = result as? [Post] { posts = p }
        }

        guard let user else { throw AppError.missingData }
        return DashboardData(user: user, posts: posts)
    }
}

For fault-tolerant operations where partial results are acceptable, use a non-throwing group with try?:

Swift
func fetchAvailableImages(urls: [URL]) async -> [UIImage] {
    await withTaskGroup(of: UIImage?.self) { group in
        for url in urls {
            group.addTask { try? await self.downloadImage(from: url) }
        }

        var images: [UIImage] = []
        for await image in group {
            if let image { images.append(image) }
        }
        return images
    }
}

Typed Throws in Swift 6

Swift 6 introduced typed throws via throws(ErrorType), letting you specify exactly which error type a function can throw:

Swift
enum ParseError: Error {
    case invalidFormat
    case missingField(String)
    case typeMismatch(expected: String, got: String)
}

func parseConfig(from data: Data) throws(ParseError) -> Config {
    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
        throw .invalidFormat
    }

    guard let name = json["name"] as? String else {
        throw .missingField("name")
    }

    return Config(name: name)
}

Inside the catch block, error is automatically typed, so you get exhaustive switch handling without casting:

Swift
do {
    let config = try parseConfig(from: data)
    apply(config)
} catch {
    // error is ParseError, not any Error
    switch error {
    case .invalidFormat:
        print("Bad format")
    case .missingField(let field):
        print("Missing: \(field)")
    case .typeMismatch(let expected, let got):
        print("Expected \(expected), got \(got)")
    }
}

Typed throws also works with generics. A function that accepts a throwing closure can propagate the exact error type:

Swift
func retry<T, E>(
    times: Int,
    operation: () throws(E) -> T
) throws(E) -> T {
    for attempt in 1..<times {
        do {
            return try operation()
        } catch {
            continue
        }
    }
    return try operation()
}

If the closure is non-throwing, E becomes Never and the whole function becomes non-throwing. The compiler handles this automatically.

That said, typed throws is best suited for internal APIs with stable error contracts. For public library APIs or code where error types might evolve, untyped throws remains more flexible and is still the recommended default for most Swift code.

Layered Error Architecture

In production apps, define error types at each layer and map between them:

Swift
// Network layer
enum APIError: Error {
    case unauthorized
    case notFound
    case serverError(Int)
    case networkFailure(underlying: Error)
}

// Domain layer
enum UserServiceError: Error {
    case notFound
    case sessionExpired
    case unavailable
}

class UserService {
    private let api: APIClient

    func getUser(id: Int) async throws(UserServiceError) -> User {
        do {
            return try await api.fetch(User.self, from: "/users/\(id)")
        } catch let error as APIError {
            switch error {
            case .unauthorized: throw .sessionExpired
            case .notFound: throw .notFound
            default: throw .unavailable
            }
        } catch {
            throw .unavailable
        }
    }
}

This separation means the UI layer never sees raw API errors, and changes to your networking stack don't ripple through the entire app.

Common Mistakes

Using try? everywhere because it's easy. This swallows error information and makes debugging painful. If a Keychain read fails, knowing whether it was a missing item vs. a permission error changes your response entirely.

Catching errors too early. If a utility function catches and logs an error internally, the caller never knows something failed. Let errors bubble up to the layer that has enough context to respond meaningfully, usually the view or view model.

Forgetting catch clause order. Swift evaluates catches top to bottom. A generic catch before a specific catch let error as NetworkError means the specific clause never runs. Always put specific catches first.

Conforming to Error with a class instead of an enum. While any type can conform, enums naturally enforce a finite set of cases and work with exhaustive switch statements. Classes make error handling harder to reason about.

// Frequently_Asked

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.