Error handling in Swift: Do-catch, typed throws & async

Most Swift error handling breaks not because developers skip do-catch, but because they pick the wrong mechanism for the failure mode, using try! where a Result would survive, or untyped throws where Swift 6 now expects precision.

Senior teams shipping async-heavy codebases feel this most acutely during migrations. This guide walks through do-catch, throwing functions, try variants, Result, and Swift 6's typed throws — including how a legacy throw/catch codebase moves to typed, user-facing error reporting.

Swift error handling at a glance

Swift's Error protocol requires only an empty conformance, and that low bar is exactly why production code misuses it constantly. Error handling breaks into four layers: throwing functions, do-catch blocks, try? and try! for optional bailouts, and typed throws for a constrained error type.

These layers matter even more once error handling intersects with concurrent async workloads, where GCD queues introduce their own timing and thread-safety considerations.

Unlike JavaScript's try/catch or Python's broad exception hierarchy, Swift pushes developers to model failure as a concrete type, usually an enum with associated values, not a generic thrown object. SE-0413 formalized typed throws in Swift 6, the first shift in error propagation since throws shipped in Swift 2.0.

In our work with Swift 6 migrations, teams that skip this layering tend to bury real failures behind try? until an audit finally surfaces them.

This piece covers do-catch syntax, the typed throws migration path, async/await error handling, and where the Result type still earns its place.

How Swift's do-catch block works

A do-catch block routes whatever a throwing function throws to the first matching catch clause, matched by error type or associated value, not just by presence. Swift compiles this against enum Error cases, so each catch can destructure the specific failure instead of testing a generic exception object the way JavaScript or Python catch blocks do.

enum NetworkError: Error {
 case invalidResponse(code: Int)
 case timeout
}

do {
 let data = try fetchUser(id: 42)
 process(data)
} catch NetworkError.invalidResponse(let code) where code >= 500 {
 logServerFailure(code)
} catch {
 logGenericFailure(error)
}

The where clause and associated-value binding are what separate a do-catch block from a plain try/catch: matching happens on error content, not just error presence.

Catch-all blocks that swallow every error case are a common way to mask a real problem, like a timeout treated the same as a parsing failure. Splitting the generic catch into typed cases surfaces issues like that before they reach production.

Declaring and calling throwing functions

A throwing function marks its declaration with throws right after the parameter list, and any call site must prefix the call with try. The compiler enforces this at compile time, not runtime, which is why Swift catches a missing try before the app ever ships.

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

Calling parseConfig without try fails to compile. Propagation happens automatically: a throwing function calling another throwing function forwards the error up the call stack unless it's caught locally, so one do-catch block at the top of a call chain can handle failures from several layers of throwing functions below it.

Rethrows covers a narrower case: a function that doesn't throw on its own but takes a throwing closure as a parameter. map, filter, and similar higher-order functions use rethrows so they only propagate an error if the closure passed to them throws one.

Per Apple's Swift Programming Language guide, this distinction lets the type system express conditional throwing precisely, something Python's blanket except and JavaScript's try/catch around any callback cannot express at the signature level.

Try vs try? vs try!: Choosing the right one

Three Swift keywords call a throwing function — try, try?, and try! — and each reacts differently the moment it throws: propagate, swallow to nil, or crash.

Plain try propagates the error up to a surrounding do-catch block. try? swallows it and returns nil, folding success and failure into a single optional. try! asserts the call will never throw and crashes with fatalError if that assumption is wrong.

Form On success On failure When we reach for it
try returns value propagates to catch default choice inside a throwing context
try? returns Optional(value) returns nil, error details lost UI code that only cares whether data loaded
try! returns value crashes the process one-time setup you have already proven can't fail

In Swift 6, per Apple's Error Handling documentation, try! is the only propagation style that turns a thrown error into an unconditional crash, closer to JavaScript letting an unhandled rejection kill a process than to Python's forgiving try/except.

Flag every try! outside test targets and startup code during review — most of the time they turn out to be disguised force-unwraps someone added to silence the compiler, not decisions anyone would defend in code review.

Matching multiple catch clauses with where conditions

Where clauses let a single do block branch across multiple catch clauses instead of nesting switch statements inside error handling. Define your errors as an enum Error type with associated values, then narrow each catch by case and by a condition on that value.

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

do {
 try session.fetch()
} catch NetworkError.invalidResponse(let code) where code >= 500 {
 retryQueue.add(operation: .refetch)
} catch NetworkError.invalidResponse(let code) where code == 401 {
 session.reauthenticate()
} catch {
 log.error("Unhandled: \(error)")
}

Each clause matches the case first, then evaluates the where condition; a failed condition falls through to the next catch, not to the default. Apple's Swift Programming Language Error Handling chapter documents this cascading match order, and Swift 6, per SE-0413, leaves it untouched even when a function moves to typed throws.

The missing catch-all is a recurring gap: a where condition that never fires will simply fall through, leaving the error to propagate unhandled.

Result type vs throw/catch: When to use each

Use Result to store, pass, or defer an error outcome across a boundary or completion handler; use throw/catch when the error should propagate immediately up the call stack.

A throwing function forces the caller to handle failure at the call site with try, try?, or try!. A Result<Success, Failure> value can sit in a variable, get returned from a closure, or cross an actor boundary without unwinding anything.

We default to throwing functions for synchronous, in-process error types conforming to the Error protocol, and reserve Result for callback-based APIs or where we need to combine multiple outcomes with map and flatMap before deciding how to catch them. Since async/await error handling replaced most completion-handler patterns, Result shows up less in new Swift code than it did before Swift 5, per Swift.org's async/await documentation.

Typed throws in Swift 6 narrows the gap further: a throwing function with a typed error is nearly as inspectable as a Result's Failure generic, without the wrapping overhead.

Handling errors in async/await code

Async/await error handling in Swift keeps the same throwing function and do-catch syntax you use synchronously, but Task adds a second failure channel: cancellation. That trips up developers coming from JavaScript's promise rejection model, where a rejected promise and an aborted request land in the same catch.

A thrown error and a cancelled Task are not the same signal. Cancellation is cooperative, not preemptive. Calling try Task.checkCancellation throws a CancellationError, but only if you check for it explicitly.

According to the Swift.org Swift 6 announcement, Task cancellation stayed cooperative in Swift 6, released in September 2024, so skipping that check still lets the task run to completion and waste work silently.

A retry loop that catches CancellationError alongside network errors treats user-driven cancellation as a real failure, masking what actually happened. Catch CancellationError first, propagate it up, then handle domain-specific throws underneath.

That ordering, not the syntax, is what separates correct async/await error handling from code that merely compiles. This same discipline of catching cancellation signals before generic errors appears in other structured concurrency models, like structured concurrency in Kotlin, where coroutine cancellation follows a comparable propagation pattern.

Swift 6 typed throws: Migration and examples

Swift 6 typed throws let a throwing function declare its exact error type in the signature, replacing the untyped throws every catch site had to accept. SE-0413 was accepted into Swift 6, and Apple's WWDC 2024 session on Swift concurrency updates walks through the same syntax change for async contexts.

These typed error signatures pair naturally with strongly-typed data layers, when you're wiring up a database layer like GRDB.swift, propagating a specific error type instead of any Error makes query failures much easier to handle upstream.

enum NetworkError: Error {
 case timeout
 case invalidResponse(code: Int)
}

func fetchUser() throws(NetworkError) -> User {
 guard let data = cachedData else {
 throw NetworkError.timeout
 }
 // decode...
}

The caller's catch block now gets NetworkError directly, no as? downcast, no generic Error bucket. That's the anti-pattern typed throws kills: functions that throw five unrelated error types under one umbrella, forcing every caller to pattern-match against cases that don't apply to them.

Migration is not a mechanical find-and-replace. Generic throwing functions need a single, homogeneous error type across call sites, which means auditing every enum Error conformance in a module before touching signatures. This audit-then-migrate sequence is what keeps a typed-throws rollout from stalling halfway through a codebase.

Start at the network and persistence layers, where error types are already narrow, and work outward, leave any Error at API boundaries you don't own.

User-facing error messages with LocalizedError

LocalizedError turns an opaque Error into text that a SwiftUI view or UIKit alert can show directly, replacing the generic localizedDescription fallback, "The operation couldn't be completed." Conform your error enum to LocalizedError and build errorDescription, failureReason, and recoverySuggestion as computed properties returning String?.

enum UploadError: LocalizedError {
 case fileTooLarge(sizeMB: Int)

 var errorDescription: String? {
 switch self {
 case .fileTooLarge(let sizeMB):
 return "File is \(sizeMB)MB, which exceeds the 25MB limit."
 }
 }
}

Catch the error and read error.localizedDescription, Swift resolves it through the protocol automatically, no manual case-matching required.

Bridging to Objective-C is where this gets tested, a real consideration for developers maintaining legacy codebases. Any Error crossing into an Objective-C API gets boxed as NSError, and LocalizedError's properties map onto NSError's userInfo keys (NSLocalizedDescriptionKey, NSLocalizedFailureReasonErrorKey).

Per Apple's Error Handling documentation, this bridging is automatic, but only if your enum conforms to LocalizedError before it crosses the bridge. Plain Error conformance loses the localized message during the transition.

Structuring error types across app layers

Layered Swift codebases share one mistake: network, persistence, and UI errors all flow through one generic Error type, forcing view controllers to pattern-match cases they shouldn't need to know about.

The fix is a distinct Error protocol conformance per layer, with a translation boundary between them. A repository throws PersistenceError; the service layer catches it and rethrows a DomainError case; the view layer never sees a database-specific error at all. Typed throws (SE-0413) make this boundary explicit in the function signature instead of relying on documentation.

Defer belongs at each boundary too, closing a URLSession task or rolling back a Core Data context has to run whether the function returns normally or throws, and defer guarantees that cleanup fires exactly once, in reverse declaration order, without duplicating it in every catch block.

FAQ: Swift error handling questions

Swift do catch try example

A do-catch block wraps a throwing function call: use try before the call and catch to handle whatever Error protocol conformer comes back. For example, do { try saveFile() } catch { print(error) } catches any error type, caught inside the do block regardless of case. Use this pattern whenever a function's failure needs handling.

Swift throwing function tutorial

A throwing function is declared with throws after its parameter list, and it requires callers to handle it with try, try?, or try!. For instance, func loadUser() throws -> User propagates the error up the call stack instead of returning nil silently. Skipping that propagation is the most common tutorial mistake we see in production code.

Try vs try? vs try! Swift

try propagates the thrown error type to the caller, try? converts the result to an optional and returns nil on failure, and try! force-unwraps and crashes instead. Keep try? as a quick reference for optional chaining, and reserve try! for cases where failure is truly impossible. Overusing try? hides real error context from callers.

Swift result type vs throw

Result<Success, Failure> stores a success or failure value so you can defer handling across a boundary; a thrown error instead propagates immediately and unwinds the call stack. Use Result when the caller decides later, and use throw when it should react now. Both rely on the same Error protocol underneath.

Swift async await error handling

Async/await error handling in Swift reuses the same try/catch syntax as synchronous code: an async throws function propagates errors across suspension points without a completion handler. Wrap the await call in a do block, or mark the enclosing function throws, to bubble the error further up. This replaced the callback-based handling most teams maintained before Swift 5.5 (How to Use Swift Concurrency with async/await).

Swift 6 typed throws example

Typed throws let a Swift 6 function declare its exact error type in the signature: func parse() throws(ParseError) -> Data, formalized by SE-0413. Migrating a networking layer to throws(NetworkError) removes redundant as? casts from every catch block. Use it only when a function throws one closed error set, not a mix of unrelated types.

LocalizedError Swift example

LocalizedError extends the Error protocol with errorDescription, failureReason, and recoverySuggestion properties, so UI code can show a message without a manual switch case. For example, enum LoadError: LocalizedError { var errorDescription: String? { "File not found." } } feeds SwiftUI's alert text directly. Use it whenever an error needs to reach a user-facing view.

Swift error handling best practices

Prefer specific Error protocol enums with associated values over generic strings, and reserve try! for cases where failure is truly impossible. JavaScript and Python raise runtime exceptions by default, while Swift forces an explicit throws, which catches more logic errors at compile time. Don't let try? hide errors your users need to see.

Next steps: Auditing your app's error handling

Start an error handling audit by grepping for try? and force-unwrapped try!, then check whether each throwing function's Error cases still make sense under Swift 6 typed throws. Silent failures hide fastest in try? chains, so that scan alone often surfaces the riskiest code paths on the page.

Migrating swift codebases from generic catch blocks to typed throws requires touching every call site that currently handles any Error, which is why teams usually pair the audit with a phased rollout rather than a big-bang rewrite.

If your team is weighing that migration path, talk to our team. We work across swift and javascript codebases and can review your current error architecture before you commit engineering time to it. This is especially relevant if you're building or maintaining backend services, since your choice among server-side Swift frameworks can shape how error handling and async patterns are built across the stack.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business