Swift Error Propagation: Throw, Catch, and When to Crash
Error propagation in Swift isn’t just a language feature you tack on at the end. It’s a design decision that shapes your entire API surface, threading model, and even how your app recovers—or doesn’t—in the field. Between throws, Result, typed throws, and the occasional deliberate try!, you’ve got a lot of tools. The trick is knowing which one keeps your production app from turning into a house of cards.

How Swift’s Error Model Actually Works
Swift’s error handling is built on a simple contract: a function marked throws can exit with an error instead of a value, and the caller must acknowledge that possibility. The compiler enforces this at compile time, so you can’t just forget a try and let an error vanish into the ether. This is a deliberate departure from the invisible propagation common in C++ or the two-track Result type favored in functional programming. The Swift approach, refined through the Swift Evolution process, forces you to confront failure points explicitly.
But the compiler only checks that you’ve written some handling code. It doesn’t check that your handling is correct, meaningful, or even safe. That’s where the real work begins.
The Four Paths for an Error
Every error in your app eventually takes one of four paths. Choose the wrong one, and you’re either swallowing critical diagnostics or crashing on a transient network blip.
1. Direct Propagation (Rethrow)
Mark your function with throws and call the failing function with bare try. The error travels up the call stack unchanged, preserving its type and context. This is the cleanest option for utility functions that sit in the middle of a chain. The downside? Every caller in that chain must now be throwing-aware. Overuse it, and you’ll find yourself adding throws to functions that really shouldn’t need it.
2. Wrapping and Converting
Catch a specific error and throw a different one. This is non-negotiable at module boundaries. Your networking layer shouldn’t leak URLError to your view models. Catch it, wrap it in a domain-specific APIError, and attach the original as an associated value. Skip that last step, and you’ll be staring at a crash log with no idea what actually went wrong.
3. Optional Conversion (try?)
Turn the error into nil. This makes sense when the caller genuinely doesn’t care why something failed—only whether it succeeded. Think fetching a cached thumbnail that may or may not exist. The mistake I see over and over is sprinkling try? everywhere because it’s convenient. It’s not. It’s a debugging black hole. Reserve it for operations where failure is expected and truly non-critical.
4. Fatal Conversion (try!)
Turn the error into a crash. This is acceptable in exactly one scenario: the error represents a programmer mistake that should never happen in a correctly written program. Loading a bundled resource that’s verified at build time? Fine. A network timeout? Absolutely not. If you’re using try! to avoid writing error handling, you’re shipping a bug.

Designing Error Types That Scale
A single enum AppError: Error that every module imports is a trap. It looks tidy at first, but it grows into a monster that couples unrelated parts of your codebase. Instead, define error types per module or per component, each with associated values that capture the context you’ll need when something breaks at 2 a.m.
Picture a three-layer setup: networking, repository, and view model. The networking layer throws NetworkError with cases like timeout, serverError(statusCode:), and noConnectivity. The repository layer catches those and throws RepositoryError—maybe networkUnavailable or dataCorrupted. The view model maps those into user-facing messages or retry states. Each layer speaks its own language, and the mapping between them is explicit and testable.
This approach respects the Single Responsibility Principle and makes unit testing almost pleasant. You can verify that a NetworkError.timeout maps to a RepositoryError.networkUnavailable without mocking the entire stack.
Typed Throws: Swift 5.9’s Sharpest New Tool
Swift 5.9 introduced typed throws, letting you declare the exact error type a function can throw. The proposal, SE-0380, fixes a long-standing annoyance: callers had no compile-time clue what errors might come out of a function. Now you can write func fetch() throws(NetworkError) and the compiler enforces it.
Typed throws shine when combined with generics and Swift Concurrency, where error types flow through the type system. But there’s a trade-off. If you later need to throw a different error type, it’s a source-breaking change. For public libraries, that’s a serious commitment. For internal app code, the added safety usually wins.

Structured Concurrency and Error Propagation
Swift’s async/await model changes the game. In a task group, if one child task throws, the error propagates to the parent and cancels all sibling tasks. This is cooperative cancellation—child tasks need to check Task.isCancelled or call Task.checkCancellation() to bail out early. Forget that, and you’ve got tasks running longer than they should, potentially corrupting state.
A common blunder is wrapping an entire task group in a generic catch block. You lose track of which operation failed. Handle errors at the most granular level you can, and only escalate when the error genuinely stops forward progress.
With async let bindings, errors are deferred until you await the value. If you never await a throwing async let, the error is silently ignored. The compiler warns about unused values, but it’s easy to miss in complex control flow. Explicitly cancel unused async let bindings to avoid resource leaks.
Bridging Result and Throws
Plenty of Apple frameworks still use completion handlers with Result types, while modern Swift code prefers async throws. The bridge is withCheckedThrowingContinuation. It suspends the current task and resumes it when the completion handler fires, converting the Result into a thrown error or returned value.
Watch out for continuation misuse. Resuming a continuation more than once is a fatal error. If your completion handler might fire multiple times, wrap the continuation in an Optional and nil it out after the first resume. I’ve lost hours to crashes caused by this exact mistake.
Logging and Observability
Errors that propagate silently are technical debt. Every catch block should log enough context to reconstruct the failure. Use structured logging with OSLog, and include the error’s localized description, the file and line where it was caught, and any relevant request identifiers.
For production apps, consider a lightweight error aggregation service. The open-source Sentry project captures unhandled errors and provides breadcrumb trails. But be selective. Logging every handled error creates noise that buries real signals. Reserve breadcrumbs for errors that cross module boundaries or represent unexpected states.
Testing Error Paths
Happy-path tests are easy. Error-path tests are where your test suite earns its keep. For every throwing function, write tests that verify the correct error type is thrown under each failure condition. Use XCTAssertThrowsError and pattern-match on the error’s type and associated values.
When testing asynchronous error propagation, use XCTest expectations with timeouts. A test that doesn’t assert on the specific error within a reasonable timeframe is a false positive. Set explicit timeouts and assert that the expected error arrives before the timeout expires.
For code that uses try?, write tests that verify the nil path is actually reachable. It’s surprisingly common to have try? calls that can never fail in practice, which means the nil-handling branch is dead code. Either remove the try? or construct a test case that exercises the failure path.
FAQ
When should I use a throwing function versus returning a Result type?
Use throwing functions for synchronous operations where the caller is expected to handle the error immediately. Use Result when you need to store or pass an error value around without handling it right away, such as in a completion handler that will be called later. With Swift Concurrency, throwing functions are generally preferred because they integrate naturally with async/await and task groups. Reserve Result for cases where you need to capture an error and defer its handling outside the concurrency system.
Is it ever acceptable to use try! in production code?
Yes, but only for errors that represent programmer mistakes, not runtime conditions. Loading a bundled resource like a JSON file that is verified at build time is a defensible use of try!. If the file is missing, the app is fundamentally broken and crashing early is better than limping along in an undefined state. Never use try! for network calls, user input parsing, or anything that depends on external state.
How do I handle errors in SwiftUI views without cluttering my view code?
Move error handling logic into an ObservableObject view model. The view model can catch errors from data operations and expose them as published properties, such as an alertItem that the view observes. This keeps your SwiftUI views declarative and focused on presentation. For operations triggered by user actions, use a task modifier that catches errors and routes them to the view model’s error state.
What’s the best way to handle errors in a Combine pipeline?
Use the catch operator to replace errors with a fallback publisher or a default value. If you need to convert the error into a different type for the downstream subscriber, use mapError. Avoid letting errors terminate a subscription that should be long-lived, such as a network status monitor. Instead, handle the error within the pipeline and continue emitting values.