Elm's update Function: Letting the Compiler Catch the State Transition You Forgot
Elm's update function must handle every message constructor or the build fails. Here's how the exhaustiveness check works, where wildcard branches break it, and how to verify it in your own project.
24 Feb 2026, 07:20 UTC

A missing branch that only fails on the path nobody clicks
In a JavaScript or TypeScript reducer, adding an action is cheap. You add Reset to the union, write the handler, and move on. If you forget the handler, the switch falls through to default, the reducer returns the previous state, and the button that dispatches Reset does nothing. No exception, no console warning, no failing test unless someone wrote one for that exact path. The bug ships and waits for a user.
Elm's answer to this is not a lint rule or a runtime assertion. It is the type checker. In the Elm Architecture, every state change flows through one function, and that function's case expression must be total: it must have a branch for every constructor of the message type. If it doesn't, elm make fails.
What "total" means for update
The signature is fixed: update : Msg -> Model -> ( Model, Cmd Msg ). It takes a message and the current model, and returns a new model plus any commands to run. Because Msg is a custom type (Elm's version of a tagged union), the compiler knows exactly which constructors exist.
When you write case msg of and omit one, the build stops. In Elm 0.19 the error points at the case expression and names the constructors that have no branch. This is part of type checking rather than an optional strictness flag, so it holds for every build, including the optimized one you ship.
The same rule applies to the model whenever you model state as a custom type instead of a record of loose fields.
The second place it pays off: the state type
A record like { items : List Item, error : Maybe String, loading : Bool } can represent states that shouldn't exist, such as loading and failed at the same time. A custom type rules those out:
type Status
= Idle
| Loading
| Loaded (List Item)
| Failed String
Now every function that cases on Status — view, update, a helper that extracts a page title — must handle Failed. Add a constructor later and each of those functions becomes a compile error until it's addressed. That is the trade: the compiler hands you a to-do list instead of a production bug.
Worked example: adding a message to a counter
Here is a small counter. Note that the case has no wildcard branch.
type alias Model =
{ count : Int
, step : Int
}
type Msg
= Increment
| Decrement
| SetStep Int
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment ->
( { model | count = model.count + model.step }, Cmd.none )
Decrement ->
( { model | count = model.count - model.step }, Cmd.none )
SetStep newStep ->
( { model | step = newStep }, Cmd.none )
Now suppose you add Reset to Msg and forget the branch. The next elm make fails and names Reset as the constructor with no matching pattern. Add the branch and it compiles. Nothing here is clever; the point is that the failure lands in your terminal rather than in a user's browser.
Where the guarantee leaks
- Wildcard branches. Elm lets you write
_ ->as the last branch of acase. It absorbs every constructor you add afterward, silently, and the compiler stops helping. If yourupdatecontains a wildcard, you have opted out of the check. Debug.todo. It compiles and crashes at runtime when reached. In Elm 0.19,elm make --optimizefails on modules that importDebug, so it cannot ride along in an optimized build — but it can reach a development or staging build. Confirm this against your own toolchain rather than assuming.- Verbosity. One branch per message means long
updatefunctions in large apps. The usual mitigation is to split: a top-levelupdatedispatches to per-pageupdatefunctions, each with its ownMsgtype. Exhaustiveness then applies at whatever level you actually pattern match. - Cost of change. Adding a constructor to a widely used type forces edits everywhere it is matched. That is the feature working as intended, but on a large codebase it is real work, and it is worth designing state types deliberately rather than growing them casually.
Checking whether your project still has the guarantee
- Run
elm make src/Main.elmfrom the project root and confirm the build is clean. Fix any existing exhaustiveness errors first, since they indicate unreachable or unhandled states. - Add a temporary constructor to your
Msgtype without a branch, rebuild, and confirm the compiler rejects it. Remove it afterward. If the build succeeds, something in yourupdateis swallowing the new case — most likely a wildcard. - Search your update modules for
_ ->. Each occurrence is a place where a future constructor will be handled by accident rather than by decision.
One limitation worth stating plainly: exhaustiveness covers state transitions that are expressed as message or state constructors. It says nothing about whether an individual handler is correct. A SetStep branch that accepts a negative step still compiles. The check removes one class of bug, not all of them.
What to do with this
The practical move is to treat wildcard branches in update as a code smell and to model meaningful state as custom types rather than combinations of booleans and Maybe. If update has grown unwieldy, split it by page or feature before reaching for a catch-all. The compiler's exhaustiveness check is only as strong as the patterns you give it, and it is cheapest to keep intact from the start.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.