Eliminating Impossible UI States with Elm Custom Types
Stop fighting contradictory UI states. Learn how to use Elm's custom types to make invalid states—like showing a loading spinner and an error message simultaneously—impossible to represent.
01 Aug 2025, 01:57 UTC

The Problem: The State Synchronization Trap
In many frontend frameworks, it is common to track the status of an asynchronous request using multiple boolean flags. You might have isLoading = true, hasError = false, and userData = null. While this seems intuitive, it creates a combinatorial explosion of possible states. What happens if isLoading and hasError are both true? Or if userData exists but isLoading is still true?
These are "impossible states." When your model allows them, your view logic must account for them—usually through a series of fragile if/else blocks. If you forget one combination, the user sees a blank screen or a loading spinner that never disappears, even though an error occurred.
The Thesis: Make Invalid States Unrepresentable
The most effective way to handle UI state in Elm 0.19.x is not to add more validation logic, but to design a model where invalid combinations cannot be constructed. By using Custom Types (also known as Union Types), you can collapse multiple booleans into a single type that represents exactly one state at a time.
Moving from Booleans to Custom Types
Instead of tracking loading and error states separately, define a specific type for the remote data. This ensures that the application is always in exactly one of the defined phases: not yet started, currently fetching, failed, or successfully loaded.
type RemoteData profile
= NotAsked
= Loading
= Failure Http.Error
= Success profile
By wrapping your profile data in this type, the Success state is the only place where the profile record actually exists. You cannot accidentally try to render the profile while the app is still in the Loading state because the data simply isn't there to be accessed.
Worked Example: A Profile Loader
Consider a simple profile page. In this example, the Model doesn't hold a profile and a loading flag; it holds a RemoteData Profile.
-- The Model
type alias Model = { profileState : RemoteData Profile }
-- The Messages
type Msg
= FetchProfile
= GotProfile (Result Http.Error Profile)
-- The Update
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchProfile ->
( { model | profileState = Loading }, fetchProfileCmd )
GotProfile result ->
case result of
Ok profile ->
( { model | profileState = Success profile }, Cmd.none )
Err err ->
( { model | profileState = Failure err }, Cmd.none )
The View: Exhaustive Pattern Matching
The real power appears in the view function. Because Elm requires exhaustive pattern matching, the compiler forces you to handle every possible state of the RemoteData type.
view : Model -> Html Msg
view model =
case model.profileState of
NotAsked ->
button [ onClick FetchProfile ] [ text "Load Profile" ]
Loading ->
text "Loading..."
Failure _ ->
div [] [ text "Error loading profile", button [ onClick FetchProfile ] [ text "Retry" ] ]
Success profile ->
div [] [ text ("Welcome, " ++ profile.name) ]
The Compiler as a Refactoring Tool
Imagine your requirements change: you need a Retrying state that shows a different animation than the initial Loading state. In a boolean-based system, you would add a new flag and hunt through every if statement in your app to ensure it doesn't conflict with isLoading.
In Elm, you simply add | Retrying to your RemoteData type. Immediately, the compiler will flag every case expression in your update and view functions as an error. It provides a precise todo list of every single line of code that needs to be updated to support the new state.
Trade-offs and Limitations
This approach introduces more boilerplate. You cannot simply access model.profile.name; you must first unwrap the RemoteData type using a case expression or a helper function. For very small components, this can feel verbose.
Additionally, while this prevents logic errors in the UI state, it does not eliminate all runtime failures. You still need to handle potential failures at the boundaries of your system, such as JSON decoders. If the server returns a 200 OK but the JSON body is malformed, the Http.get call will return an Err, which you must map to your Failure state.
Verification
To verify this pattern in your own project:
- Define your state as a Custom Type rather than multiple
MaybeorBoolfields. - Run
elm make src/Main.elm. - Intentionally remove one branch (e.g., the
Failurecase) from yourviewfunction. - Confirm that the compiler prevents the build and explicitly names the missing constructor.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.