Swift Typed Throws: The Complete Guide to Type-Safe Error Handling

Swift 6 introduced typed throws — finally bringing type safety to error handling. Learn the syntax, async/await integration, Result comparison, API design strategies, and step-by-step migration patterns for your codebase.

Swift 6 brought one of the most anticipated features in the language's history: typed throws. If you've ever written a do-catch block and felt annoyed by that mandatory generic catch clause at the bottom — the one you know will never execute — you'll appreciate what typed throws brings to the table. Before this, every throwing function used the plain throws keyword, meaning errors got boxed as any Error. The compiler had zero clue what specific error type a function could actually produce.

Typed throws changes that. You can now declare the exact error type a function throws, and suddenly your error handling gets the same type safety you're used to everywhere else in Swift.

In this guide, we'll walk through how typed throws works, when you'd want it over untyped throws or Result, how it plays with async/await and Swift concurrency, and the practical patterns that'll make your error handling cleaner and more reliable.

How Error Handling Worked Before Swift 6

Since Swift 2, error handling has followed the do-catch-throw pattern. You define an error type conforming to Error, throw it from a function marked throws, and catch it at the call site:

enum NetworkError: Error {
    case invalidURL
    case timeout
    case serverError(statusCode: Int)
}

func fetchData(from urlString: String) throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }
    // ... networking code
}

do {
    let data = try fetchData(from: "https://api.example.com/users")
} catch let error as NetworkError {
    switch error {
    case .invalidURL: print("Bad URL")
    case .timeout: print("Request timed out")
    case .serverError(let code): print("Server returned \(code)")
    }
} catch {
    // Required even though we know all possible errors
    print("Unknown error: \(error)")
}

See the problem? Even though fetchData only throws NetworkError, the compiler can't know that. You're forced to include a general catch clause every single time — kind of like writing an exhaustive switch with a default case you know will never run.

Honestly, it always felt like a gap in the language. Swift gives you exhaustive pattern matching everywhere else, but error handling was the odd one out.

What Are Typed Throws?

Typed throws, introduced via SE-0413, lets you specify the exact error type a function can throw using the syntax throws(ErrorType):

func fetchData(from urlString: String) throws(NetworkError) -> Data {
    guard let url = URL(string: urlString) else {
        throw .invalidURL  // Abbreviated syntax works!
    }
    // ... networking code
}

With this declaration, the compiler now knows exactly what fetchData can throw. And that unlocks some really nice capabilities:

  • Exhaustive catch blocks — no more generic catch required when you handle every case
  • Abbreviated throw syntax — write throw .invalidURL instead of throw NetworkError.invalidURL
  • Compile-time verification — the compiler refuses any attempt to throw an error that doesn't match the declared type
  • Better performance — no existential boxing overhead since the error type is known at compile time

That last point is easy to overlook, but it matters more than you'd think (especially on the server side).

Typed Throws Syntax Deep Dive

Basic Function Declaration

The throws(ErrorType) annotation goes right where the regular throws keyword would:

enum ValidationError: Error {
    case emptyField(fieldName: String)
    case tooShort(minimum: Int)
    case invalidFormat
}

func validateUsername(_ username: String) throws(ValidationError) -> String {
    guard !username.isEmpty else {
        throw .emptyField(fieldName: "username")
    }
    guard username.count >= 3 else {
        throw .tooShort(minimum: 3)
    }
    guard username.allSatisfy({ $0.isLetter || $0.isNumber }) else {
        throw .invalidFormat
    }
    return username
}

Clean and straightforward. Notice how you can use the abbreviated throw .emptyField(...) syntax — no need to spell out the full enum name.

Exhaustive do-catch Blocks

Here's where it gets satisfying. When you call a function with typed throws, the error in your catch block is automatically typed — no casting needed:

do {
    let name = try validateUsername(input)
    print("Valid username: \(name)")
} catch {
    // error is automatically ValidationError, not any Error
    switch error {
    case .emptyField(let field):
        print("\(field) cannot be empty")
    case .tooShort(let min):
        print("Must be at least \(min) characters")
    case .invalidFormat:
        print("Only letters and numbers allowed")
    }
    // No default case needed — this is exhaustive!
}

No as? casting. No leftover default case. Just a clean, exhaustive switch that the compiler can fully verify. This is how it should've always worked.

Explicit do-catch Typing

You can also annotate the do block itself with a specific error type:

do throws(ValidationError) {
    let name = try validateUsername(input)
    let email = try validateEmail(input)  // Must also throw(ValidationError)
} catch {
    switch error {
    case .emptyField(let field): handleEmptyField(field)
    case .tooShort(let min): handleTooShort(min)
    case .invalidFormat: handleInvalidFormat()
    }
}

Understanding throws(Never) and throws(any Error)

This part is really cool once it clicks. Typed throws has two special cases that reveal how Swift models error handling under the hood:

// A non-throwing function is actually:
func greet() throws(Never) -> String {
    return "Hello!"
}

// A regular throwing function is actually:
func riskyOperation() throws(any Error) -> Int {
    // can throw anything conforming to Error
}

So throws(any Error) is equivalent to plain throws, and throws(Never) is equivalent to not having throws at all. Makes sense when you think about it — Never has no instances, so you literally can't throw anything.

Where this gets really powerful is in generic code. You can propagate the error type of a closure argument:

func transform<T, E: Error>(
    _ value: Int,
    using closure: (Int) throws(E) -> T
) throws(E) -> T {
    try closure(value)
}

// Non-throwing closure: E is inferred as Never
let result = transform(42) { $0 * 2 }  // No try needed!

// Typed throwing closure: E is inferred as ValidationError
let validated = try transform(42) { value throws(ValidationError) -> String in
    guard value > 0 else { throw .invalidFormat }
    return "\(value)"
}

This effectively replaces many uses of rethrows with something that's both more flexible and more expressive. I'd argue it's one of the most underrated parts of this feature.

Typed Throws with Async/Await

Typed throws integrates seamlessly with Swift concurrency. You just combine async and throws(ErrorType) together:

enum APIError: Error {
    case invalidEndpoint
    case decodingFailed
    case unauthorized
    case rateLimited(retryAfter: TimeInterval)
}

struct APIClient {
    func fetchUser(id: String) async throws(APIError) -> User {
        guard let url = URL(string: "https://api.example.com/users/\(id)") else {
            throw .invalidEndpoint
        }

        let (data, response) = try? await URLSession.shared.data(from: url)
            ?? { throw .invalidEndpoint }()

        guard let httpResponse = response as? HTTPURLResponse else {
            throw .invalidEndpoint
        }

        switch httpResponse.statusCode {
        case 200:
            guard let user = try? JSONDecoder().decode(User.self, from: data) else {
                throw .decodingFailed
            }
            return user
        case 401:
            throw .unauthorized
        case 429:
            let retry = httpResponse.value(forHTTPHeaderField: "Retry-After")
                .flatMap(TimeInterval.init) ?? 60
            throw .rateLimited(retryAfter: retry)
        default:
            throw .invalidEndpoint
        }
    }
}

And at the call site, you get a fully typed async error handling experience — no downcasting, no guessing:

Task {
    do {
        let user = try await apiClient.fetchUser(id: "123")
        await updateUI(with: user)
    } catch {
        switch error {
        case .invalidEndpoint:
            showAlert("Service unavailable")
        case .decodingFailed:
            showAlert("Data format error")
        case .unauthorized:
            navigateToLogin()
        case .rateLimited(let retryAfter):
            scheduleRetry(after: retryAfter)
        }
    }
}

Building Error Chains Across Application Layers

Real apps aren't just one function calling another. You've got networking, data access, business logic, UI — each layer producing its own flavor of errors. Typed throws helps you model clean error propagation across these boundaries without losing context along the way.

// Layer 1: Network
enum NetworkError: Error {
    case connectionFailed
    case timeout
    case invalidResponse(statusCode: Int)
}

// Layer 2: Repository (wraps network errors)
enum RepositoryError: Error {
    case networkFailure(NetworkError)
    case cacheMiss
    case dataCorrupted
}

// Layer 3: Use case / Business logic (wraps repository errors)
enum UserProfileError: Error {
    case repositoryFailure(RepositoryError)
    case userNotFound
    case insufficientPermissions
}

// Each layer uses typed throws to propagate its own error type
struct NetworkService {
    func request(_ endpoint: URL) async throws(NetworkError) -> Data {
        // ...
    }
}

struct UserRepository {
    let network: NetworkService

    func fetchUser(id: String) async throws(RepositoryError) -> User {
        do {
            let data = try await network.request(endpoint)
            return try JSONDecoder().decode(User.self, from: data)
        } catch let networkError as NetworkError {
            throw .networkFailure(networkError)
        } catch {
            throw .dataCorrupted
        }
    }
}

struct GetUserProfileUseCase {
    let repository: UserRepository

    func execute(userId: String) async throws(UserProfileError) -> UserProfile {
        do {
            let user = try await repository.fetchUser(id: userId)
            guard user.isActive else { throw UserProfileError.userNotFound }
            return UserProfile(from: user)
        } catch let repoError as RepositoryError {
            throw .repositoryFailure(repoError)
        }
    }
}

This pattern preserves the complete error chain — each layer adds its own context while keeping the original error accessible for logging or debugging.

The really nice part? In the UI layer, you can pattern-match all the way through the chain to show exactly the right message:

do {
    let profile = try await useCase.execute(userId: "abc")
} catch {
    switch error {
    case .repositoryFailure(.networkFailure(.timeout)):
        showRetryDialog()
    case .repositoryFailure(.networkFailure(.connectionFailed)):
        showOfflineMessage()
    case .userNotFound:
        showUserNotFound()
    case .insufficientPermissions:
        navigateToUpgrade()
    default:
        showGenericError()
    }
}

Nested pattern matching like that? Chef's kiss.

Typed Throws vs. Result: When to Use Which

Before typed throws existed, Result<Success, Failure> was the way to get type-safe error handling in Swift. Now that we have both, it's worth understanding where each one shines.

Feature Result<T, E> throws(E)
Compile-time error type safety Yes Yes
Native do-catch syntax No (requires map/flatMap) Yes
Performance Existential boxing Direct (no boxing)
Store as a value Yes No (control flow only)
Multiple error types Via enum composition Single type only
Functional chaining map, flatMap, mapError Not applicable
Callback APIs Ideal Not applicable

Use typed throws when: you're writing synchronous or async throwing functions and want compile-time safety, exhaustive catch blocks, and better performance.

Use Result when: you need to store a success/failure value as a property, pass it around as a function parameter, chain operations with map and flatMap, or work with callback-based APIs that haven't been migrated to async/await yet.

In practice, I've found that typed throws covers about 80% of the cases where I used to reach for Result. But Result still has its place — especially when you need to hold onto an error value rather than just react to it in a catch block.

Designing APIs with Typed Throws

Typed throws is powerful, but it does constrain your API surface. Here's how to think about when and where to use it.

Internal and Module-Level Code

This is where typed throws absolutely shines. When you control both the throwing function and all its callers, the benefits are clear and the tradeoffs are minimal:

// Internal to your app — perfect for typed throws
enum AuthError: Error {
    case invalidCredentials
    case sessionExpired
    case accountLocked(until: Date)
    case twoFactorRequired(challenge: String)
}

final class AuthService {
    func login(email: String, password: String) async throws(AuthError) -> Session {
        // Implementation
    }

    func refreshToken(_ token: Token) async throws(AuthError) -> Token {
        // Implementation
    }
}

Public SDK and Library APIs

For public APIs, you need to think a bit harder. Adding a new error case to your enum is a breaking change for anyone with exhaustive catch blocks. Here are a few strategies to consider:

// Strategy 1: Use a frozen enum if the error set is truly fixed
@frozen
enum DatabaseError: Error {
    case connectionFailed
    case queryFailed(String)
    case transactionAborted
}

// Strategy 2: Keep untyped throws for maximum flexibility
public func query(_ sql: String) throws -> [Row] { ... }

// Strategy 3: Use a struct-based error for forward compatibility
public struct SDKError: Error {
    public let code: Code
    public let message: String
    public let underlyingError: (any Error)?

    public enum Code: Int, Sendable {
        case unknown = 0
        case networkFailure = 1
        case authenticationRequired = 2
        case rateLimited = 3
    }
}

Strategy 2 is usually the safest default for libraries. It's not as fancy, but your future self (and your users) will thank you for the flexibility.

Generic Code and Rethrows Replacement

One of the biggest wins — and honestly the part that gets me most excited — is using typed throws to replace rethrows in generic code. The old rethrows pattern only works for simple cases. Typed throws handles the complex stuff:

// Old rethrows pattern — limited
func retry<T>(times: Int, task: () throws -> T) rethrows -> T { ... }

// New typed throws pattern — flexible
func retry<T, E: Error>(
    times: Int,
    task: () throws(E) -> T
) throws(E) -> T {
    var lastError: E?
    for _ in 0..<times {
        do {
            return try task()
        } catch {
            lastError = error
        }
    }
    throw lastError!
}

Performance Benefits

Let's talk performance. Typed throws offers measurable improvements over untyped throws. With the old approach, the runtime has to:

  1. Box the error value into an existential container (any Error)
  2. Store type metadata alongside the value
  3. Perform type casting (as? MyError) when you want to inspect the error

With typed throws, the compiler knows the exact error type at compile time. The error value gets stored and passed directly — no existential container, no type metadata, no runtime casting.

For most app code, this difference is negligible. But it starts to matter in performance-critical paths: tight loops, embedded Swift targets, and high-throughput server-side code where you might be handling thousands of errors per second.

Known Limitations and Caveats

Typed throws isn't perfect. Here are the rough edges you should know about before diving in.

Single Error Type Only

You can't specify multiple error types in a throws clause. There's no throws(A, B, C) syntax:

// This does NOT compile:
// func process() throws(NetworkError, ValidationError) -> Data

// Solution: Create a unified error enum
enum ProcessingError: Error {
    case network(NetworkError)
    case validation(ValidationError)
}

func process() throws(ProcessingError) -> Data { ... }

It's a bit of extra boilerplate, but the wrapper enum approach works well in practice.

Mixed Throwing Functions in do Blocks

When a do block calls functions that throw different typed errors, the compiler falls back to any Error:

do {
    let user = try fetchUser()        // throws(NetworkError)
    let validated = try validate(user) // throws(ValidationError)
} catch {
    // error is any Error here, not a specific type
    // because the two functions throw different types
}

To keep typed error handling, you'll need to either wrap each call separately or create a unified error type that covers both. A minor annoyance, but worth knowing about.

Evolution Constraints

Adding a new case to an error enum used with typed throws is a source-breaking change. Callers with exhaustive switch statements won't compile anymore. So plan your error types carefully before committing to typed throws in public APIs — or use one of the strategies from the API design section above.

Migration Guide: Adopting Typed Throws in Existing Code

Working in a Swift 6 codebase and want to start adopting typed throws? Here's a practical, step-by-step approach that won't turn your project upside down:

  1. Start with leaf functions — find functions at the bottom of your call stack that throw a single, well-defined error type. These are the easiest candidates.
  2. Define focused error enums — create error types scoped to each layer or module. Resist the urge to make one giant error enum for the whole app (trust me on this one).
  3. Wrap cross-layer errors — when a function calls another typed-throws function from a different layer, catch and wrap the error in your layer's own error type.
  4. Update call sites — remove those generic catch clauses and replace as? casts with direct switch statements on the now-typed error.
  5. Keep untyped throws at boundaries — your top-level UI or API handlers can stay with plain throws for maximum flexibility. You don't need typed throws everywhere.
// Before: untyped throws
func saveProfile(_ profile: Profile) throws {
    guard profile.isValid else {
        throw ProfileError.invalid
    }
    try database.save(profile)
}

// After: typed throws at each layer
func saveProfile(_ profile: Profile) throws(ProfileError) {
    guard profile.isValid else {
        throw .invalid
    }
    do {
        try database.save(profile)
    } catch let dbError as DatabaseError {
        throw .saveFailed(dbError)
    }
}

The migration doesn't have to be all-or-nothing. Start small, pick a module, and expand from there as you get comfortable with the patterns.

Frequently Asked Questions

What is the difference between throws and typed throws in Swift?

Regular throws allows a function to throw any error conforming to the Error protocol, and the compiler treats the error as any Error. Typed throws uses the syntax throws(ErrorType) to specify the exact error type a function can throw, enabling exhaustive catch blocks, abbreviated throw syntax, and better performance by eliminating existential boxing.

Can I use typed throws with async/await?

Absolutely. Typed throws integrates fully with Swift concurrency. Declare your function as async throws(MyError) and call it with try await. The error type flows right through to the catch block, giving you the same type-safe handling in async code as in synchronous code.

Should I use typed throws or Result in Swift 6?

Use typed throws for synchronous and async functions where you want native do-catch syntax with compile-time safety. Use Result when you need to store success/failure as a value, pass it as a parameter, or use functional chaining with map and flatMap. In most cases, typed throws should be your first choice — it's got better performance and cleaner syntax.

Does typed throws work with Swift concurrency TaskGroup?

Yes, it does. You can use withThrowingTaskGroup and have individual tasks use typed throws. The task group itself uses untyped throws by default, but within each task closure, you can leverage typed throws for your internal logic and error handling.

When should I avoid using typed throws?

Avoid it in public library APIs where the error set might grow over time, since adding a new case is a breaking change. Also skip it when a function calls multiple other functions that throw different error types — the compiler will just fall back to any Error anyway. For most public-facing APIs, plain throws is still the safer default.

About the Author Editorial Team

Our team of expert writers and editors.