This post originally appeared on the NoRedInk blog.
One of the joys of writing Elm apps is the many ways the compiler catches our mistakes, and the useful error messages it gives us when it does. It's a two-way street though, meaning that the degree to which the compiler can weed out mistakes depends on what information we disclose to it. In other words, we have to help the compiler help ourselves. In this post we'll see that this is truer for the view function than anywhere else, and we'll see how we can help the compiler help us get our view functions right.
A book recommendation application
Suppose we're building a web page showing the latest book releases of your favorite authors, ordered by popularity. The main page of the app is a simple list of books, listing the title, author name, and the ISBN of each book. Lets create a model for this application.
type alias Model =
{ authors : List Author
}
type alias Author =
{ name : String
, books : List Book
, favorite : Bool
}
type alias Book =
{ title : String
, isbn : String
, popularity : Int
}
Next up is the view function of this application. For this view we need to transform the data from our model a bit. First of all we're not interested in displaying books of authors the user has not favorited, so we can filter those out. Secondly, we want to order the books of the remaining authors by popularity. Thirdly we want to display items on the page that contain data from the Book
(title, isbn) and Author
(name) types.
Intermezzo: Are we sure we want to do this?
But wait, maybe instead of writing a transformation function it would be easier to change our Model
type to more closely resemble the structure of the page! For example, suppose our model looks like this:
type alias Model =
{ books : List Book
}
type alias Book =
{ authorName : String
, authorIsFavorite : Bool
, title : String
, isbn : String
, popularity : Int
}
Now our view function will be much easier to write. But this model comes with a disadvantage. Two books of the same author can disagree on whether that author is one of the users favorites. The previous model prevented this impossible state because the favorite
status of an author was only stored in a single place. When faced with this trade-off we prefer our model to prevent as many impossible states as possible, even if our view function will need do some transformations.
View transformations at NoRedInk
Many of the Elm apps we have at NoRedInk have a model that looks quite different from the Html we draw on the screen. As a result our view functions will need to transform data, but that's not a problem, especially if the compiler helps make sure we're transforming correctly. But there's the rub: we'll need to work a little bit to get the compiler to help us here.
To understand why, let's look at a transformation present in every Elm app: The update function that transforms an old model state into a new one. The compiler helps us a lot with this one, it ensures that regardless of what happens our update function will always return a valid new model, which prevents all sorts of bugs. But when it comes to the view function we get no such guarantee, the compiler only ensures it returns Html. If all goes well that Html will look like our specs, but it might as well be the image of a cat as far as the compiler is concerned. Both are Html and so equally valid results of calling our view function.
To prevent such bugs we put some extra constraints on what we consider valid output from a view function. We can do this by defining a new type describing the shape of our page. Our view function we then split into two parts. First we transform our model into this View
type, this is the hard part. Then we take that type and render it into Html. Because the type was designed to be similar to the structure of the page, this second part should be easy.
Let's transform some books
Let's get back to our book recommendation app and write a View
type for it.
type alias View =
List BookView
type alias BookView =
{ authorName : String
, title : String
, isbn : String
, popularity : Int
}
Now we can write the transformation function that turns our Model
into a View
type. This is the hardest part of the job, but because the transformation takes place between two specialized types Model
and View
, we will get a lot of help from the Elm compiler.
toView : Model -> View
toView model =
model.authors
|> List.filter .favorite
|> List.concatMap booksForAuthor
booksForAuthor : Author -> List BookView
booksForAuthor author =
author.books
|> List.map
(\{ title, isbn, popularity } ->
{ authorName = author.name
, title = title
, isbn = isbn
, popularity = popularity
}
)
|> List.sortBy .popularity
Finally, all that's left is to write the view function that renders our View
type to Html
.
view : Model -> Html msg
view model =
toHtml (toView model)
toHtml : View -> Html msg
toHtml books =
Html.ul [] (List.map viewBook books)
viewBook : BookView -> Html msg
viewBook book =
Html.li []
[ Html.text ("Author: " ++ book.authorName)
, Html.text ("Title: " ++ book.title)
, Html.text ("ISBN: " ++ book.isbn)
]
Conclusion
We've seen how we can use a View
type to write our view function using some extra help from the compiler.
As a nice side-effect, this approach also makes it easy to unit-test our view logic. We can write ordinary Elm unit tests for our toView
function, which contains all of our actual transformation logic. This allows us to write less tests against our Html
returning view function, which although possible is harder.
Stöffel
@schtoeffel
Engineer at NoRedInk
Jasper Woudenberg
@jasperwoudnberg
Engineer at NoRedInk
Top comments (3)
I encounter similar issues when decoding JSON responses. The types and fields coming in the response are not a 1:1 mapping of what your program actually needs. Do you use a similar technique there? For example, separate
BookPayload
,Book
andBookView
types?Oh yes, that's a great example! Yeah, we tend to create a type that's very similar to the structure of the JSON we receive through flags or http responses. Then write a transformation functions from those
Flags
orBackend
types to aModel
. And when encoding some data to JSON, for example as the body of a request, the same trick can be used there too!Nice technique. This smells like another one of those things that people (unfairly) criticize Elm (and other FP languages) for because the effective techniques for handling common patterns are very different from those found in languages of the other paradigms. Novices are not likely to discover the technique in their own code writing and therefore their frustrations dealing with a lesser-effective technique are likely to be targeted toward Elm itself; fair or otherwise. I still find it difficult to communicate these subtleties with beginners and wonder how Elm will face these challenges. Maybe I heard it somewhere else, maybe not, but I carry a motto for Elm in my mind that says "it's broken until it's easy for everyone", and maybe another one that says "if it makes things harder, we're not adding it." I'm excited to see how Elm and the community will make better and better abstractions that enable even higher level programming with even less complexity. Thanks for sharing your post.