There are a number of errors that occur, in any software application, during the course of normal program execution. The way we represent and handle these errors can significantly impact the quality and maintainability of our software. If an ill-fitting abstraction is used, error handling can be nightmarish and bug-prone. Alternatively, the right abstraction can lighten our cognitive load and help crystalize the understanding of our code.
I have had the fortunate opportunity to work exploring and extending a relatively large and mature project written primarily in Elm over the past couple of months. It has been my first professional experience using a functional language. Consequently, many of the error handling techniques I've used before don't translate well into Elm, and I've had to learn to use new ones.
Luckily, the engineering team at SMRxT works hard to foster an environment where knowledge and experience are freely shared. To that end, here are a number of patterns that we have found useful. I'll assume only a casual familiarity with Elm's syntax and functional vocabulary (ie, maps and union types, not monads and contra-maps).
Pattern 1: Maybe Error
Every error handling pattern is, at its core, a Maybe
; either an error exists, or it does not. There are, however, a number of elaborations on this core pattern. These variations are not simply syntactic sugar - they empower the engineer to associate errors with relevant contextual information.
The most straightforward way to represent an error in Elm is to keep a Maybe
type on the model that contains an Error type. This is a relatively simple construction that will be built upon as we climb the abstraction ladder.
type Maybe a
= Just a
| Nothing
type Error
= MalformedFoo
| MisalignedBar
type alias Model =
{ ...
, error : Maybe Error
}
This simple, but powerful, pattern is useful when a module needs to track the state of a single error, or when the context only allows for one error at a time. If there are potentially many different errors on a page, the best current solution is to keep a discrete error on the model for each. If kept as a list, the difficulty of handling errors uniquely emerges; some may prompt retries or need to be rendered at different locations, for example.
Although it is tempting to represent the error as a String
, this should be avoided if possible. It is preferable to use the Error
returned from a request, or a custom Error type, as it gives us valuable information about the kind of error and communicates its intent more clearly than a String
. This additionally provides type safety, which is always nice.
Helper functions are commonly used to display the error. The general form takes this shape:
renderErrorSection model =
case model.error of
Just errorMsg ->
div [ class "alert alert-danger" ]
[ text <| errorToString errorMsg ]
Nothing ->
emptyElement
A potential step sideways on the abstraction ladder leads us to a variation of the Maybe Error. An example can be found in etaque/elm-form (https://github.com/etaque/elm-form/blob/master/src/Form/Error.elm).
type ErrorValue e
= Empty
| InvalidString
| InvalidEmail
...
| CustomError e
Although this may seem, at first glance, different and more descriptive than the Maybe Error
pattern, it is simply more verbose. In fact, this can be transformed into the form:
type ErrorValue e
= Empty
| Error e
type Error e
= InvalidString
| InvalidEmail
...
| CustomError e
Which is completely isomorphic to the Maybe Error
pattern. The core Http.Error
wrapped in a Maybe
is a perfect example of this:
type Error
= BadUrl String
| Timeout
| NetworkError
| BadStatus (Response String)
| BadPayload String (Response String)
Pattern 2: Action Result
We often find ourselves in a situation where we want to represent more than just Error and Not Error. An elaboration on the Maybe Error
pattern is a union type that makes a distinction between No Result and Success.
type ActionResult e a
= NoResult
| Failure e
| Success a
This pattern gives us the ability to keep the view in sync with the real state of our data and the world. We can, for example, display an alert informing the user about the result of a remote API call, which is hidden while it waits for a response:
displayAlert : ActionResult Error Data -> Html Msg
displayAlert result =
case result of
NoResult ->
emptyElement
Success data ->
div [ class "alert alert-success" ]
[ displayData data ]
Failure error ->
div [ class "alert alert-danger" ]
[ text ( errorToString error) ]
errorToString
is a useful utility function that provides the ability to turn an Error type into a string descriptor. Its construction takes the form of a case error of
, switching on each possible error state. The core toString
function works on Http.Error
for this purpose. It is a generally good practice to create an equivalent toString
function for any Error
union that is created.
We often find ourselves in a situation where we want to represent the state of a request at a more granular level. Kris Jenkins' RemoteData
package is an incredibly useful and versatile implementation of this pattern. There is a great blog-post, here (http://blog.jenkster.com/2016/06/how-elm-slays-a-ui-antipattern.html), that further explains the motivations behind the package. A RemoteData
is a union type representing 1 of 4 possible states.
type RemoteData e a
= NotAsked
| Loading
| Failure e
| Success a
With RemoteData
, changes to the user interface can be linked closely to updates in the application state. This is especially useful for displaying loading icons or placeholder text during long-running API calls.
Pattern 3: Initialized Model
The strategies described so far are all similar in that they can be coerced into the Maybe Error form and kept on the model. In an exciting twist, the model itself can be used to represent the state of some error. This is a perfect mechanism to represent a model if its initialization can fail.
type Model
= Initialized App
| InitializationError Error
type alias App =
{ currentPage : Page
, currentLocation : Location
, appContext : AppContext
}
init : Json.Value -> Location -> ( Model, Cmd Msg )
init flags location =
let
appContextResult =
Json.decodeValue AppContext.appContextDecoder flags
in
case appContextResult of
Ok appContext ->
let
( app, cmdMsg ) =
setRoute (Route.fromLocation location)
{ currentPage = Blank
, currentLocation = location
, appContext = appContext
}
in
( Initialized app, cmdMsg )
Err error ->
( InitializationError error, Cmd.none )
In this example, the model depends on the result of decoding some context. If this decoding fails, we simply assign the failure state to our model. If it is successful, we assign the success state with the decoded App
. This can be extremely useful, but can also lead to tedium, as the model must be destructured anywhere it's used, as it's not a record, but a union type. It can be helpful to create two different update functions, like so:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case model of
Initialized app ->
Tuple.mapFirst Initialized <| updateApp msg app
InitializationError _ ->
( model, Cmd.none )
updateApp : Msg -> App -> ( App, Cmd Msg )
updateApp msg app =
case msg of
Foo bar ->
( { model | thing = bar }, Cmd.none )
Then the view might look something like this:
view : Model -> Html Msg
view model =
case model of
InitializationError error ->
div [ class "center" ]
[ text <| errorToString error ]
Initialized app ->
displayApp app
This pattern involves a significant amount of boilerplate, but works especially well for root level main modules that direct messages and commands to sub-modules.
Conclusion
The patterns and their variations listed here are certainly not an exhaustive list of error handling techniques, but I've found them all extremely useful in different situations. I hope that you find them useful in your own exploration of Elm and functional programmin
By the way, we are looking for highly skilled software engineers to join our team. Check out our job listing to learn more!