Error handling is the part of an app that gets written last and shows first. The failure mode is always the same shape: a spinner that never stops, or an alert saying "Error" with a message meant for a developer.
This lesson is a model that makes both hard to write. A typed error whose cases are decisions, a presenter that decides what a customer sees, and a state enum that makes the eternal spinner literally unrepresentable.
An error type whose cases are decisions
The instinct is to model errors after the places things go wrong: badURL,
serverError, parseFailed. That produces a long enum and a
switch in every caller that maps most of it onto the same behaviour.
Model the decisions a caller can make instead:
enum APIError: LocalizedError, Equatable {
case api(status: Int, message: String, body: APIErrorBody?)
case unauthorized
case network(message: String)
case decoding(message: String)
Four cases and each one leads somewhere different. .api means the server answered
and said no, carrying field-level messages a form can render. .unauthorized is split
out because it is the one status that means "sign in again" rather than "show this message" — giving
it its own case means a caller cannot forget it by accident. .network means the request
never arrived, which on a phone usually means a lost signal. And .decoding means we
reached the server and could not understand it, which is our bug and should be logged as
one.
LocalizedError
var errorDescription: String? {
switch self {
case let .api(_, message, _): message
case .unauthorized: "Your session has expired. Please sign in again."
case let .network(message): message
case .decoding: "The server returned a response this app could not read."
}
}
Conforming to LocalizedError rather than bare Error is what makes
error.localizedDescription return the message above instead of "The operation couldn't
be completed. (Pizza.APIError error 3.)" — which is the string customers actually report seeing in
apps that skip this.
Notice .decoding does not surface its detail. The customer cannot act on a type
mismatch, and the detail is already in the log.
Field errors, without the ceremony
var fieldErrors: [String: String] {
guard case let .api(_, _, body) = self, let subErrors = body?.errors else { return [:] }
return subErrors.reduce(into: [:]) { result, subError in
if let field = subError.field { result[field] = subError.message }
}
}
Two decisions in six lines. Sub-errors with no field are dropped, because a message with nowhere
to render must not become a phantom key. And it returns an empty dictionary rather than an optional
for every non-API case, so a view can write errors[field] without first asking which
kind of failure it is dealing with.
Retryability belongs on the error
var isRetryable: Bool {
switch self {
case .network: true
case let .api(status, _, _): status >= 500
case .unauthorized, .decoding: false
}
}
A 400 will fail again with exactly the same body, so offering "Try again" is a lie. A dropped connection might not. Putting the judgement on the error means every screen's retry button is gated on the same rule instead of each one guessing.
Deciding what the customer sees
Every screen needs the same ladder — is this an API error, a network error, something else — and writing it in each one guarantees they diverge:
enum ErrorPresenter {
static func message(for error: Error, fallback: String = "Something went wrong.") -> String? {
if error is CancellationError { return nil }
if let urlError = error as? URLError, urlError.code == .cancelled { return nil }
if let apiError = error as? APIError { return apiError.errorDescription ?? fallback }
return error.localizedDescription.isEmpty ? fallback : error.localizedDescription
}
The return type is optional, and that is the interesting part. A cancelled
request means the view went away — the customer dismissed a screen, or a search superseded itself.
There is nothing to tell them, so the presenter returns nil and every caller's "show a
message" path is skipped by construction.
Getting that wrong produces one of the more baffling bugs in an async app: an error toast that appears after you have already left the screen that caused it.
The state of a screen, as one value
Here is the piece that does the most work. Three properties — isLoading,
error, items — describe eight combinations, and five of them are nonsense:
loading and failed, loaded and failed, empty and loading. Every one is a bug somebody can write, and
the most common one ships constantly.
enum ViewState<Value> {
case idle
case loading
case loaded(Value)
case empty
case failed(message: String, isRetryable: Bool)
// accessors and the two constructors below follow
Five states, all of them real. A view switches over it, the compiler insists every
case is handled, and "what does this screen show right now?" has exactly one answer at all times.
.empty is separate from .loaded([]) deliberately: an empty result is a
destination, not a degenerate success. It wants different copy and usually a call to
action, and treating it as a list of zero rows gives you a blank screen that looks broken.
Two constructors that remove the mistakes
static func resolved(_ collection: Value) -> ViewState where Value: Collection {
collection.isEmpty ? .empty : .loaded(collection)
}
static func failure(_ error: Error, fallback: String = "Something went wrong.") -> ViewState {
ViewState.failed(
message: ErrorPresenter.message(for: error, fallback: fallback) ?? fallback,
isRetryable: (error as? APIError)?.isRetryable ?? true
)
}
resolved means no caller has to remember the isEmpty ternary — and more
usefully, none of them can forget it. failure carries the retryability through, so the
"Try again" button appears exactly when retrying could work.
What a store does with it
guard !Task.isCancelled else { return }
state = catalogue.products.isEmpty ? .empty : .loaded(catalogue)
} catch {
// A cancellation is not a failure — it means a reload superseded this load.
guard !Task.isCancelled, !ErrorPresenter.isCancellation(error) else { return }
state = .failure(error, fallback: "Could not load the menu.")
}
The fallback is the screen-specific part. "Could not load the menu" is what appears
when the error has no message of its own — which is better than "Something went wrong" because it at
least tells the customer which thing failed.
And what a view does
switch menu.state {
case .idle, .loading:
LoadingStateView(label: "Loading the menu…")
case let .failed(message, isRetryable):
ErrorStateView(message: message, retry: isRetryable ? { menu.load() } : nil)
case .empty:
EmptyStateView(title: "Nothing on the menu", message: "The kitchen has not published anything yet.")
Exhaustive. You cannot ship this screen having forgotten the failure branch, because it would not compile.
Three states, three components
The three views live in one file because they are one decision, not three. A screen that renders a spinner but forgets the error branch is the single most common bug in a fetch-and-render app, and keeping them together makes the omission visible.
VStack(alignment: .leading, spacing: Spacing.xs) {
Text("Something went wrong").textStyle(.bodyStrong, tone: .danger)
Text(message).textStyle(.caption, tone: .muted)
}
.accessibilityElement(children: .combine)
Combining the two lines means VoiceOver announces "Something went wrong, could not load the menu" as one stop rather than two unrelated fragments. The retry button is deliberately outside that group — combining children hides them from the accessibility tree, so a button nested inside becomes unreachable. That is the classic way this pattern goes wrong, and it makes the screen worse for exactly the people the effort was for.
Writes are a different shape
A read has five states. A write has three outcomes, and one of them is neither success nor failure:
enum ActionOutcome: Equatable {
case succeeded(String)
case failed(String)
case cancelled
Why not Result<String, String>? Because it does not compile —
Result's failure type must conform to Error, and a message is a
String. The deeper reason is that this is not a Result: both cases carry
the same thing, a sentence to show the customer.
.cancelled is the case a Result has nowhere to put. The customer backed
out of a payment sheet or dismissed a confirmation — nothing should be shown at all. Modelling that
as .failed("") that every caller remembers to special-case is how an empty toast
eventually reaches production.
private func report(_ outcome: ActionOutcome) {
guard let message = outcome.message else { return }
toasts.show(message, style: outcome.isSuccess ? .success : .danger)
}
One place turns an outcome into a toast, so no call site can forget the failure case and none of them can show an empty one.
Not every failure is worth reporting
Two in the demo app are deliberately swallowed, and both are worth the judgement call.
A failed cart save is logged, not surfaced. The cart still works for this session; it simply will not survive a relaunch. A toast on every tap would be a worse experience than the failure it reports.
And saved addresses that will not load do not block checkout — the screen falls back to typing one. Failing the whole flow because an optional convenience is unavailable turns a minor outage into a lost order.
The rule underneath both: report a failure when the customer can do something about it, or when not reporting it would leave them confused about what happened.
Testing the model
All of this is plain values, so the tests need nothing:
func testRetryabilityReflectsWhetherRetryingCouldWork() {
XCTAssertTrue(APIError.network(message: "offline").isRetryable)
XCTAssertTrue(APIError.api(status: 503, message: "", body: nil).isRetryable)
XCTAssertFalse(APIError.api(status: 400, message: "", body: nil).isRetryable)
XCTAssertFalse(APIError.unauthorized.isRetryable)
XCTAssertFalse(APIError.decoding(message: "").isRetryable)
}
Five assertions, no simulator, no server. The cancellation cases are worth testing explicitly too
— that ErrorPresenter.message(for: CancellationError()) is nil is a claim
the rest of the app depends on, and it is one line to prove.
What about crashes?
Everything above is about expected failure — a server saying no, a network dropping. A crash is different: force-unwrapping a nil, an array index out of range, a precondition failing. Those are not errors to handle, they are bugs to fix, and trying to recover from them generally makes them harder to find.
The two practical rules are worth stating. Do not force-unwrap in app code — the demo app's linter treats it as an error and allows it in tests, where a crash is a clear failure. And when a programming error genuinely is unrecoverable, crash deliberately with a message that names the fix. The app does this exactly once, for a release build with no API host configured: shipping a binary that silently talks to localhost is worse than one that refuses to launch on the machine of the person who can still correct it.
Next
Several snippets here have checked Task.isCancelled without explanation. The next
lesson is Swift concurrency properly — async let, .task, cancellation you
get for free, @MainActor, and an actor for state several callers touch at once.