The Error Model
Exceptions are the last place most languages allow spooky action at a distance. Any call might throw; the handler is some unknown number of frames up; the type signature says nothing. Mar has no exceptions, so it owes you an answer: where do failures go?
The answer is a taxonomy. Three kinds of failure, three homes, each chosen so that the type system forces exactly the right people to care.
1. Transport failures: the shared union
Some failures can strike any service call, because networks and sessions are mortal: the request never left the device, the session expired, the server fell over. Every Service.call therefore delivers Result Service.Error resp, where the error side is one shared union:
type Service.Error
= Offline -- the request never reached the server
| Unauthorized -- session gone (the server said 401)
| ServerError String -- the server refused; carries its message
Your page’s case on the result must have an Err branch (exhaustiveness again), and can be as coarse or fine as the UX deserves:
Fetched (Err Service.Offline) ->
( { model | banner = Just "You appear to be offline. Retry?" }, Cmd.none )
Fetched (Err why) ->
( { model | banner = Just (Service.errorToString why) }, Cmd.none )
The insight worth stealing even outside Mar: transport failure is not part of any endpoint’s domain, so it should never appear in endpoint response types, and no endpoint should be able to forget it. One union, delivered uniformly by the runtime, achieves both.
2. Domain outcomes: the response type
“Email already taken.” “Wrong code.” “Name empty.” These are not failures of infrastructure; they are legitimate answers to a request, and each service has its own. So they live where answers live: in the service’s declared response, as a union of what can actually happen:
type SignupOutcome
= Created User
| EmailTaken
| TeamFull
signup : Service NewUser SignupOutcome
signup = Service.declare POST "/signup"
Now follow the consequences around the loop. The handler is NewUser -> Task SignupOutcome: it cannot produce an undeclared outcome. The frontend matches:
Done (Ok (Created user)) -> ...
Done (Ok EmailTaken) -> ...
Done (Ok TeamFull) -> ...
Done (Err why) -> ...
Every outcome handled, or no compile. When the product grows a fourth outcome, the compiler distributes the work: every page that calls signup lights up until it says what the new case looks like on screen. Compare the string-matching alternative (if err.message === "EMAIL_TAKEN"), which drifts silently the day someone reworks the message.
The design rule: never a shared catch-all for domain errors. A page should only have to match what can actually happen to it. A global AppError union with forty constructors makes every caller handle thirty-seven impossibilities, which trains programmers to write the catch-all arm that defeats the whole system.
3. Broken invariants: the abort
Some states should be impossible: the row a valid session points to has vanished; a computation produced garbage. These are bugs, not outcomes; no page has a sensible branch for them. Task.fail "session user missing" aborts the handler, and the client sees it as ServerError with the message, display-only.
The test for which home a failure belongs in is behavioral, and worth memorizing:
If a page would branch on it, it is a domain outcome. If all a page can do is apologize, it is transport or an abort.
Words belong to the view
Notice that nothing on the wire is human language. Constructors cross the network; sentences do not. EmailTaken reaches the frontend as a value, and each frontend (web, iOS, a future localization) chooses its own words. Error copy is a rendering concern, so it lives in view with the rest of the rendering. Backends that send English sentences to be shown verbatim have quietly made the server responsible for UX and made translation impossible; Mar’s split keeps each side owning what it can actually do well.
The model in one table
| Failure | Example | Where it lives | Who must handle it |
|---|---|---|---|
| Transport | offline, 401, crash | Service.Error, in every call’s Err | every page, via exhaustiveness |
| Domain | email taken, wrong code | the service’s response union | pages calling that service |
| Invariant | “impossible” state | Task.fail, surfaces as ServerError | nobody branches; it is displayed and logged |
Three homes, no exceptions, no invisible control flow, and the compiler enforcing that the people who can react do. The framework’s own features follow the same pattern, which brings us to the largest of them.