How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them at layer boundaries?
Predict first
A function with a typed throw is called with a failing input. What does the catch know?
enum LoadError: Error { case offline, notFound }
func load(_ id: Int) throws(LoadError) -> String {
if id < 0 { throw .notFound }
return "item\(id)"
}
do {
print(try load(1))
_ = try load(-1)
} catch {
print(error == .notFound)
}
- item1 then true
- item1 then false
- It does not compile
Predict first
A boundary translates low-level errors into a domain error. What does it return for a known and an unknown error?
enum NetworkError: Error { case timeout }
enum AppError: Error, Equatable { case offline, unknown }
func map(_ e: Error) -> AppError {
(e as? NetworkError) == .timeout ? .offline : .unknown
}
print(map(NetworkError.timeout), map(CancellationError()))
- offline unknown
- unknown unknown
- offline offline
Mental model. Errors are like a hospital triage: the ambulance crew’s observations (low-level errors) are translated at the door into a category the ward understands, and the patient (the user) is told what happens next, not the crew’s shorthand.
Pick the mechanism by what the caller can do. An optional says “there may be no value” and nothing more (a lookup that finds nothing is not an error). throws is the default for operations that can fail with a reason: it propagates automatically through try, works with async, and keeps the happy path unindented. Result<Success, Failure> stores the outcome as a value, which suits callbacks, storing outcomes (a load state with a failure), collecting several outcomes, and APIs that predate async; you can get() it to throw. Typed throws (Swift 6: func load(_ id: Int) throws(LoadError) -> String) names the error type in the signature, so a do/catch inside the caller knows the error is a LoadError without casting (catch { error == .notFound }) and a switch can be exhaustive; it removes boxing, which matters for embedded or performance-critical code. Use it for closed, small error sets within a module and avoid it for public APIs that may add cases, since the type becomes part of the signature (an untyped throws stays flexible). Note that closures passed to generic APIs may still infer any Error, so mixing typed and untyped throws sometimes needs explicit annotations.
The design rule: translate at boundaries. The networking layer throws URLError and decoding errors; the repository maps them to a domain error like .offline or .notFound; the view model maps those to what the user sees (a message and an action: retry, sign in). Without translation, low-level details leak upward (a view checking for NSURLErrorTimedOut) and every caller must know every layer. Model errors as enums with associated values where callers need data (.validation(field: String), .rateLimited(retryAfter: Duration)), make them Equatable or Sendable as needed, conform to LocalizedError only at the presentation edge, and keep the underlying error as a payload for logging (.unknown(underlying: Error)). Distinguish recoverable errors (return or throw) from programmer errors (precondition, assertion, fatalError: a bug, not a state to handle), do not swallow errors with try? unless losing the reason is intended, and treat CancellationError as a normal outcome, not a failure to show. Document which errors an API can throw, since Swift’s untyped throws hides that from the signature.
With typed throws the error in the catch is already a LoadError, so it can be compared directly without a cast.
The mapping function is the single place that knows both vocabularies, so callers only see the domain error.
Common traps
- Letting
URLErrororDecodingErrorreach the view layer. Callers must know every layer’s errors; map them to domain errors at the boundary. - Using
try?everywhere. It discards the reason, so the user cannot be told what happened or what to do.
They might follow up with
- When is typed throws a good fit? Small, closed error sets inside a module; avoid it on public APIs that may grow new cases.
- Should cancellation be shown as an error? No, it is a normal outcome of leaving a screen.
Related questions
- 15.5.1 What makes a good Swift API: naming, argument labels, defaults, ownership, thread documentation and misuse…
- 13.5.1 What is the repository pattern, and why map storage models to domain models?
- 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…