Swift Error Propagation: From throw to Result and Beyond
Most Swift developers get comfortable with throws early on. It’s a clean, familiar way to bail out when something goes wrong. But in a real, production-scale app, error propagation is less about marking a function and more about a series of deliberate choices. How you model, map, and handle failures shapes everything from your app’s stability to how easily your team can reason about control flow. Let’s walk through the mechanics—throwing functions, Result types, async/await, and structured concurrency—and the tradeoffs that come with each.
Swift’s Error Model: A Quick Refresher
At its core, Swift’s error handling rests on the Error protocol. Any type that conforms can be thrown and caught. That’s a lot of flexibility, but it’s also a trap. Without a clear taxonomy, you end up with catch blocks that either swallow everything or try to parse localizedDescription strings—a brittle, un-Swifty mess. The compiler won’t force you to be exhaustive, so the responsibility is yours.
In production, a flat enum rarely cuts it. Picture a networking layer: you’ve got connectivity failures, HTTP status codes, and decoding glitches. One giant enum with every possible case becomes unwieldy fast. Nesting enums to mirror your architecture is a better move:
enum AppError: Error {
case network(NetworkError)
case persistence(PersistenceError)
case validation(ValidationError)
}
enum NetworkError: Error {
case timeout
case serverError(statusCode: Int)
case noConnectivity
}
This hierarchy lets callers catch with surgical precision. A view model can intercept a NetworkError.timeout and show a retry button, while shunting PersistenceError off to a dedicated logger. The aim: make your error types descriptive enough that a caller can pick a recovery path without ever parsing a string.
Throwing Functions and Control Flow
Slap throws on a function, and you’re telling the caller it might exit early with an error. They’ll need try, try?, or try!. In practice, try! is a code smell outside of tests or truly invariant conditions. Force-unwrapping an error crashes the process, and your users won’t thank you for it.
Inside a do-catch block, control flow jumps to the nearest matching catch clause. Synchronous code is straightforward. But async operations change the game. A throwing async function uses try await, and the error propagates to the enclosing Task or async context. With structured concurrency, if a child task throws, the parent gets cancelled—unless you handle the error explicitly. That’s a design feature, but it can catch you off guard if you’re not expecting it.
Mapping Errors with mapError
Sometimes you need to transform an error before it reaches the caller. A repository might catch a CoreDataError and map it to a domain-specific RepositoryError. In Combine, you’d use mapError. With async/await, wrap the call in a do-catch and rethrow a new error:
func fetchUser(id: String) async throws -> User {
do {
return try await remoteDataSource.fetchUser(id: id)
} catch let error as CoreDataError {
throw RepositoryError.dataAccessFailed(underlying: error)
}
}
This keeps lower-level details from leaking upward. The view model never hears about CoreDataError; it only knows about RepositoryError, which you can present or log cleanly.
The Result Type: Explicit Success or Failure
Swift’s Result type is an enum with .success(T) and .failure(Error) cases. It shines when you need to store an operation’s outcome or pass it to a completion handler. Unlike throwing functions, Result makes error handling explicit in the type signature. That can improve readability when you’re aggregating multiple independent operations.
Say you need to fetch a user’s profile and their recent orders concurrently. With async/await, you might use async let and handle errors at the point of consumption. But if you want to collect both outcomes regardless of individual failures, Result is your friend:
func loadDashboard() async -> (Result<Profile, Error>, Result<[Order], Error>) {
async let profileResult = Result { try await fetchProfile() }
async let ordersResult = Result { try await fetchOrders() }
return (await profileResult, await ordersResult)
}
This avoids short-circuiting the whole operation if one fetch fails. The caller can then decide how to present partial data. Note that Result’s init(catching:) automatically wraps thrown errors, which pairs nicely with async functions.
Structured Concurrency and Error Propagation
Swift’s structured concurrency model introduces task groups and child tasks. Errors thrown by a child task propagate to the parent. If you use withThrowingTaskGroup, the first error thrown by any child cancels all remaining children and rethrows that error. This is often what you want for dependent operations, but it can be surprising if you expected all tasks to finish.
For independent operations where you want to collect all results and errors, use withTaskGroup and handle errors manually. Each child task can return a Result type, and the group’s reduce operation can aggregate successes and failures. This pattern is common when loading multiple feeds or performing batch uploads.
func loadFeeds(urls: [URL]) async -> [FeedItem] {
await withTaskGroup(of: Result<[FeedItem], Error>.self) { group in
for url in urls {
group.addTask { await Result { try await self.fetchFeed(url: url) } }
}
var allItems: [FeedItem] = []
for await result in group {
if case .success(let items) = result {
allItems.append(contentsOf: items)
}
}
return allItems
}
}
This code silently discards errors from individual feeds, which might be fine for a non-critical dashboard. For a more resilient solution, you could return a tuple of items and errors, letting the UI display a banner for failed feeds.
Bridging Between Throws and Result
Legacy callback-based APIs often use Result in their completion handlers. When you wrap such APIs in async/await, use withCheckedThrowingContinuation to bridge the gap. This function resumes the continuation with either a return value or a thrown error, preserving the error type from the Result’s failure case.
func legacyFetch(completion: @escaping (Result<Data, NetworkError>) -> Void) { ... }
func modernFetch() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyFetch { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
This pattern is essential when incrementally adopting Swift concurrency in a large codebase. It keeps error types consistent and avoids losing information during the transition.
Designing Error Hierarchies for Production
A well-designed error hierarchy is the backbone of reliable error propagation. Start by identifying the domains in your app: networking, persistence, authentication, validation. For each domain, define a dedicated error enum that conforms to Error. Use associated values to capture context, such as the failing URL or the underlying system error. This context is invaluable for debugging and logging.
When an error crosses a layer boundary, map it to a new type. A NetworkError should not surface in a view model. Instead, the repository or use case layer should catch it and throw a DomainError that the view model can interpret. This mapping also gives you a single point to attach analytics or user-facing messages.
Be cautious with LocalizedError. While it provides human-readable descriptions, it can tempt engineers to use those descriptions for control flow. Prefer switching on error cases. Reserve localized descriptions for user-facing alerts and log messages.
Common Pitfalls and How to Avoid Them
One frequent mistake is overusing try? to silence errors. It’s convenient, but it discards all error information. If an operation fails silently, the app may end up in an inconsistent state. Use try? only when the failure is truly inconsequential, such as fetching an optional cache value.
Another pitfall is throwing overly generic errors. A function that throws Error forces callers to catch any error, which is nearly impossible to handle meaningfully. Always throw a specific type, even if it’s a simple enum with a single case. This gives callers the option to catch that specific type.
Finally, avoid mixing throwing and Result-based error handling in the same call chain without a clear boundary. This creates confusion about where errors are handled and can lead to double-handling or dropped errors. Choose one style per layer and stick to it.
FAQ
When should I use Result instead of throws?
Use throws for most synchronous and asynchronous functions where the caller is expected to handle the error immediately. Use Result when you need to store or pass an error value, such as in a completion handler, a stored property, or when aggregating multiple independent operations. Result is also useful when you want to make the possibility of failure explicit in the type signature without using a throwing function.
How do I handle errors in SwiftUI views?
SwiftUI views cannot directly call throwing functions. Instead, move the throwing call into a Task or an ObservableObject method. Use a @State or @Published property to hold an optional error, and present an alert when that property is set. This keeps your views clean and your error handling centralized.
What is the best way to log errors in a production app?
Create a dedicated error logger that conforms to a protocol, allowing you to swap implementations for development and production. Log the error’s type, its associated values, and the call site. Avoid logging sensitive user data. For critical errors, consider attaching a stack trace using Thread.callStackSymbols, but be mindful of the performance cost.
How do I test error propagation paths?
Write unit tests that verify specific errors are thrown under defined conditions. Use XCTAssertThrowsError and pattern match on the error type and its associated values. For async throwing functions, use XCTAssertThrowsError within an async test. Mock your dependencies to force error states, and test that your error mapping logic produces the expected types.
Next Steps for Your Codebase
Start by auditing your current error types. Are they scattered across the project? Do view models catch networking errors directly? Consolidate error definitions into a dedicated module or namespace. Then, pick one layer—perhaps the repository layer—and refactor it to use a clean, domain-specific error enum with mapping from lower-level errors. This incremental approach reduces risk and lets your team align on conventions before scaling out.
For further reading, the Swift Evolution proposal for Result provides valuable context on the type’s design rationale. Apple’s documentation on Error Handling is also a solid reference for the fundamentals.


