The Best Practices for Swift Error Propagation
Swift error propagation is how a function says, “I can’t finish this,” and hands the failure to a caller that might actually be able to do something about it. It sits alongside optionals, assertions, and preconditions in Swift’s failure-handling toolbox, but it’s the only one built for recoverable, expected failures that cross API boundaries. For professional developers shipping production iOS, macOS, watchOS, and tvOS apps, error propagation isn’t a language curiosity. It’s the difference between a codebase that degrades predictably when things go wrong and one that crashes or quietly corrupts state. This article covers the practices that make Swift error propagation legible, testable, and maintainable across app targets and team sizes.

Why Error Propagation Deserves More Than a Thrown Together
Most Swift developers learn throw, try, and catch early, then spend years refining how they actually use them. The language gives you a small set of keywords, but the design space is wide. A thrown error can carry a rich payload or be an empty enum case. A function can throw one error type or a dozen. A catch block can recover, translate, log, or rethrow. Each choice affects how callers reason about failure, how tests exercise edge cases, and how future maintainers trace a bug from a user report back to its origin.
Error propagation is also a contract. When you mark a function throws, you’re telling every caller that the happy path isn’t guaranteed. That contract should be as precise as the function’s parameter and return types. A vague contract produces vague handling. A precise contract lets callers write focused recovery logic and lets reviewers spot missing cases during code review.
Model Errors as Types, Not Strings
The first practice is to define error types that carry structured information. An enum that conforms to Error is the default choice, and for good reason. It groups related failures, supports exhaustive switching, and can attach associated values where needed.
enum PaymentError: Error {
case insufficientFunds(shortfall: Decimal)
case cardExpired(expiry: Date)
case networkUnavailable(underlying: URLError)
}
This beats a single case failure(String) because callers can switch on the specific case and pull out the associated values without parsing a message. It also keeps user-facing text out of the error model. A LocalizedError conformance can provide display strings later, but the core type should describe what happened, not what to show.
For errors that cross module boundaries, consider whether the enum should be public and whether its cases should be frozen. Adding a new case to a public enum is a source-breaking change for exhaustive switches in client code. If the error set is likely to grow, a struct with a domain, code, and userInfo dictionary can be more evolvable, but you lose exhaustiveness. Choose based on how stable the failure modes are.
Keep Throwing Functions Focused
A function that throws should do one thing and fail for one category of reasons. When a function can fail because of invalid input, network loss, and disk-full conditions all at once, callers can’t write meaningful recovery logic. They end up with a catch-all that logs and hopes.
Split the work. Validate input before calling the throwing function. Map lower-level errors to a domain-specific error at the boundary where the meaning changes. For example, a networking layer may throw URLError, but a repository should translate that into RepositoryError.offline or RepositoryError.serverUnavailable so that view models and tests don’t depend on Foundation’s networking types.
func fetchLatestPosts() async throws -> [Post] {
do {
return try await apiClient.fetchPosts()
} catch let error as URLError where error.code == .notConnectedToInternet {
throw RepositoryError.offline
} catch let error as URLError where error.code == .timedOut {
throw RepositoryError.timeout
} catch {
throw RepositoryError.unknown(underlying: error)
}
}
This translation layer is where error propagation becomes an architectural decision rather than a syntactic one. It keeps domain code free of transport-level details and makes the failure modes explicit in the function’s documentation and tests.
Use throws Only for Recoverable Failures
Swift’s error handling is designed for failures a caller can reasonably respond to. A programmer error, such as passing an out-of-bounds index or violating a documented precondition, should not be thrown. It should crash in development via precondition or fatalError, or be prevented by the type system.
This distinction matters because thrown errors force every caller to handle or propagate them. If the error is actually a bug, forcing callers to handle it hides the bug and spreads defensive code through the codebase. A force unwrap that crashes in development is often more honest than a thrown error that gets swallowed in production.
Reserve throws for conditions outside the programmer’s control: network failures, file permission changes, user cancellation, parsing untrusted input, and similar environmental or external failures. If the failure can be prevented by a type-safe API, prevent it instead.
Propagate with try, Not with Optional Chaining
Swift offers try? and try! as shortcuts, but they erase information. try? converts a thrown error into nil, which is useful when the caller genuinely doesn’t care why the operation failed. That’s rarer than it looks. If a cache read fails, you may want to fall back to a network fetch. If a network fetch fails, you may want to show an offline state. In both cases, the reason for the failure shapes the response.
try! is a crash waiting for a production edge case. It’s occasionally justified when the input is statically known to be valid, such as decoding a hard-coded resource, but even then a precondition with a message is clearer. Use try! only where a crash is the correct response to an impossible state, and document why the state is impossible.
The default should be try inside a do/catch or a throwing function that propagates the error upward. This preserves the full error value and keeps the failure path visible in the code.
Rethrow When You Are a Pass-Through
Functions that accept a throwing closure and call it should usually be marked rethrows rather than throws. A rethrowing function only throws if its closure throws. This lets callers pass non-throwing closures without wrapping the call in try, which keeps higher-order functions like map, filter, and custom helpers ergonomic.
func withRetry<T>(_ operation: () throws -> T) rethrows -> T {
var attempt = 0
while true {
do {
return try operation()
} catch {
attempt += 1
if attempt >= 3 {
throw error
}
}
}
}
Here, rethrows is the honest signature. The function itself doesn’t originate errors; it only passes them along. This distinction helps callers understand where failures come from and keeps non-throwing call sites clean.
Document the Error Contract
Swift doesn’t have typed throws in the same way some languages have checked exceptions, so the compiler won’t tell callers which errors to expect. Documentation comments must fill that gap. For each throwing function, list the error cases it can throw and the conditions that produce them.
/// Fetches the current user's profile.
///
/// - Throws: `RepositoryError.offline` if the device has no network connection.
/// - Throws: `RepositoryError.unauthorized` if the stored token has expired.
/// - Throws: `RepositoryError.unknown` for any other failure.
func fetchProfile() async throws -> Profile
This isn’t busywork. It’s the only reliable way for callers to know which cases to handle without reading the implementation. It also creates a review checklist: if you add a new throw site, update the documentation. If the documentation and implementation drift, the contract is broken.
Test the Failure Paths First
Happy-path tests are easy. Failure-path tests are where error propagation earns its keep. For each throwing function, write tests that exercise every documented error case and at least one unexpected error. Assert not only that the error is thrown, but that it carries the expected associated values.
func testFetchProfileWhenOfflineThrowsOfflineError() async {
let mockAPI = MockAPI(result: .failure(URLError(.notConnectedToInternet)))
let repository = Repository(api: mockAPI)
do {
_ = try await repository.fetchProfile()
XCTFail("Expected RepositoryError.offline")
} catch let error as RepositoryError {
guard case .offline = error else {
return XCTFail("Expected .offline, got \(error)")
}
} catch {
XCTFail("Expected RepositoryError, got \(error)")
}
}
Testing failure paths also reveals design problems. If a test is awkward to write because the error is hard to construct or the catch block is too broad, that’s a signal that the error model needs refinement. A well-designed error type makes failure tests as straightforward as happy-path tests.
Handle Errors at the Right Layer
Error handling should happen at the layer that can actually do something about the failure. A network client should not show an alert. A view model should not retry a request. The client propagates, the repository translates, the view model decides, and the view presents.
This layering keeps each component testable and replaceable. A view model that catches RepositoryError.offline and sets a state property to .offline can be tested without a UI. A view that observes that state and shows a banner can be tested with a UI test or snapshot test. The error flows upward until it reaches a layer with enough context to respond.
Resist the urge to log at every layer. A single log at the point where the error is handled, or at a boundary where it is translated, is usually enough. Logging at every catch produces noise and makes it harder to find the one log line that matters.
Use Result for Stored Results, Not for Every Call
Swift’s Result type is useful when a failure needs to be stored, passed across a concurrency boundary, or returned from a closure that cannot throw. It’s not a replacement for throws in ordinary synchronous or async functions. Throwing functions integrate with do/catch, try?, and rethrows in ways that Result doesn’t.
Use Result when the error is a value that will be inspected later, such as in a completion handler stored for future use, or when you need to collect multiple independent results and handle their failures together. For a single async call, async throws is almost always clearer.
Concurrency and Error Propagation
Swift concurrency adds a few considerations. An async throws function can suspend and then throw, and callers must handle both the suspension and the failure. Task groups propagate errors from child tasks, but the behavior depends on how you await them. If you use try await group.next(), an error thrown by a child task is rethrown at that point. If you use group.waitForAll(), errors are not automatically rethrown; you need to inspect the group’s results or use withThrowingTaskGroup and let the error propagate when the group exits.
func fetchAllPosts() async throws -> [Post] {
try await withThrowingTaskGroup(of: Post.self) { group in
for id in postIDs {
group.addTask { try await fetchPost(id: id) }
}
var posts: [Post] = []
for try await post in group {
posts.append(post)
}
return posts
}
}
Here, if any child task throws, the error propagates out of withThrowingTaskGroup and the remaining child tasks are cancelled. That’s often the desired behavior, but it’s worth being explicit about it in documentation and tests. Cancellation itself is not an error, but a task that checks Task.isCancelled and throws CancellationError is participating correctly in cooperative cancellation.
Localized Errors Belong at the Edge
When an error reaches the UI, it needs a user-facing message. That message should not be baked into the error type. Instead, conform to LocalizedError and provide errorDescription, failureReason, and recoverySuggestion where appropriate. This keeps the domain error type free of presentation concerns and lets the same error produce different messages in different contexts, such as a compact widget versus a full alert.
extension RepositoryError: LocalizedError {
var errorDescription: String? {
switch self {
case .offline:
return "You appear to be offline."
case .timeout:
return "The server took too long to respond."
case .unknown:
return "Something went wrong."
}
}
}
For errors that originate in Apple frameworks, you can often use the underlying NSError‘s localized description, but be careful: those messages are written for developers, not users. A URLError description may include technical details that are confusing or alarming in a user-facing alert. Translate framework errors into your own domain errors before they reach the UI.
Common Mistakes That Look Reasonable
One common mistake is catching all errors and converting them to a single generic error. This erases the information that callers need to respond appropriately. If you must catch all, at least preserve the underlying error as an associated value so that logging and debugging retain the original context.
Another mistake is throwing an error from a function that could have returned an optional. If a function can fail in exactly one way and the caller doesn’t need to know why, an optional is simpler. A func firstElement(of array: [T]) -> T? is clearer than a throwing version that throws ArrayError.empty. Use optionals for absence, errors for failure with context.
A third mistake is using errors for control flow. If a function throws ValidationError.invalidEmail and the caller immediately catches it to show a field-level message, that’s not really an error; it’s a validation result. A Result or a simple enum return value may model that more honestly. Errors should signal that an operation could not complete, not that the user typed something wrong in a form.
Error Propagation in Practice: A Small Example
Consider a view model that loads a user’s saved articles. The API client throws URLError. The repository translates that into RepositoryError. The view model catches RepositoryError and maps it to a UI state. The view observes the state and renders accordingly.
enum LoadState {
case loading
case loaded([Article])
case offline
case failed
}
@MainActor
final class ArticleListViewModel: ObservableObject {
@Published var state: LoadState = .loading
func load() async {
state = .loading
do {
let articles = try await repository.fetchSavedArticles()
state = .loaded(articles)
} catch RepositoryError.offline {
state = .offline
} catch {
state = .failed
}
}
}
This is a clean pipeline. Each layer has a single responsibility, the error types are specific, and the view model’s catch blocks are exhaustive enough to be meaningful without being so broad that they hide bugs. The same pattern scales to watchOS complications, tvOS focus-driven interfaces, and macOS document-based apps.

When to Break the Rules
Every practice has exceptions. A small script or a prototype may reasonably use try? everywhere because the cost of failure is low and the code will be replaced. A tightly scoped internal helper may throw a single error enum with one case because adding more structure would be ceremony. A performance-critical path may avoid throwing altogether and use sentinel values or optionals because the overhead of error handling, while small, is not zero.
The key is to make these exceptions deliberate. If you’re breaking a rule, know which rule you’re breaking and why. Write that reason in a comment or commit message. Future maintainers, including you in six months, will thank you.
FAQ
When should I use throws instead of returning an optional?
Use throws when the caller needs to know why the operation failed or when there are multiple distinct failure modes. Use an optional when the absence of a value is the only meaningful outcome and the reason for absence is obvious from context. A dictionary lookup returns an optional because the only question is whether the key exists. A file read throws because the file could be missing, unreadable, or corrupted, and the caller may respond differently to each.
Should I define a separate error enum for every module?
Usually yes, but keep the enums small and domain-specific. A networking module may have NetworkError with cases for connectivity, timeout, and server errors. A persistence module may have PersistenceError with cases for disk full, migration failure, and corrupted data. At module boundaries, translate lower-level errors into the module’s own error type. This keeps each module’s public API self-contained and prevents a change in one module from rippling through the entire app.
How do I handle errors in SwiftUI views?
Keep error handling out of the view body as much as possible. A view model or controller should catch errors and expose a state enum or an alert item that the view can render. SwiftUI views are declarative and should not contain do/catch blocks for business logic. If a view must call a throwing function directly, use a small wrapper that converts the result to a state value, or use the task modifier with a do/catch that updates a @State property.
What is the difference between throws and rethrows?
A function marked throws can throw errors regardless of its parameters. A function marked rethrows can only throw errors that are thrown by one of its throwing function parameters. If a rethrowing function is called with non-throwing closures, the call site doesn’t need try. This makes higher-order functions like map and custom retry helpers more ergonomic while still allowing error propagation when the closure can fail.

What to Read Next
Error propagation is one pillar of a larger failure-handling strategy. The next article in this series will cover designing result types for async pipelines, including how to combine multiple independent requests, handle partial success, and keep cancellation semantics clear. If you have a specific error-handling edge case you’d like covered, send it in and it may become a future column.