Quantcast
Channel: NoRedInk
Viewing all articles
Browse latest Browse all 193

Writing Friendly Elm Code

$
0
0

So you’ve chosen to use an elegant language designed with the programmer in mind. You’ve followed the Elm Architecture tutorial, so you understand how to structure an application. You’ve written some Elm code. Maybe you’ve even read other people’s Elm code, or taken over someone else’s Elm app. Whatever your level of experience in Elm, and despite the work the language puts in to keep your code readable and tidy, it’s possible to write some deeply unfriendly Elm code.

So why write “friendly” code? For us, the first people we have in mind when we are writing code are the students and the teachers who use NoRedInk. But we’re also writing code for each other–for other engineers–rather than writing code for just ourselves. Writing readable code can be hard, especially since not everyone agrees what is or is not readable. On my team, there are different preferences as to whether doc comments, type signatures, or descriptive naming are most important when encountering a new chunk of code. I don’t want to make an argument there (#descriptive_naming), but over the course of working with and writing Elm components of various sizes and complexities, I’ve found some general guidelines to be helpful.

I recommend that Elm programmers don’t be fancy, limit case/if..then expressions, and think of the Elm Architecture as a pattern. Obviously my opinions are fact, but feel free to have your own in public at me @t_kelly9.

Being Fancy

Being Fancy is fun. Doing something tricky, or unexpected, can feel like you’ve found The Neatest Way to solve a problem. But being fancy when modeling data makes it harder to understand that data. For a functional language, in which it’s likely you’re doing a lot of mapping and filtering, creating complex types is likely to cause frustration. Writing or modifying decoders for particularly complicated types is likely to cause active sorrow and regret.

Here’s an enthusiastically contrived example of over-complicated types:

Suppose we have a commenting component that we made for use on our media website. Users can comment on books, songs, pictures, and videos. So far, we’ve only wanted users to be able to comment with plaintext, leading to a hijacking of our platform as a means of showcasing ascii art. Our model looks like this:



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List Comment
    }

-- Comments --

type alias Comment =
    { commenter : User
    , comment : String
    , created : Date
    }

-- User --

type alias User =
    { name : String }

Embracing our userbase’s love of self-expression through images, we’ve decided to try out allowing users to comment only in the form of the media on a given page. That is, for books, the commenting system remains unchanged–users comment in words. For songs, users can comment by uploading an audio file. And so on.

Perhaps sensibly, perhaps not, we decide to create a type that describes the media type on a given view, so that we don’t get confused later and accidentally allow an audio comment on a video (that would be crazy!).



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List MediaComment
    }


-- Comment --

type alias Comment =
    { commenter : User
    , comment : String
    , created : Date
    }


type MediaComment
    = Book Comment
    | Song Comment
    | Picture Comment
    | Video Comment


-- User --

type alias User =
    { name : String }

But at this point, we still only actually support comments of type String. Is that what we want? Maybe–we can use the comment field to store an actual comment’s text content for book comments, and then just a source url otherwise, but that’s pretty simplistic. What if we want our video comments to have poster images that are attached to video comment?

Really, what we want to do is extend the idea of a Comment record with a meta idea about a comment and the comment’s contents. Sounds like record extensibility, right?



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List MediaComment
    }


-- Comment --

type alias Comment a =
    { a
    | commenter : User
    , created : Date
    }


type alias StringCommentContents =
    { comment : String }


type alias UrlCommentContents a =
    { a
    | src : String
    }


type alias TypeCommentContents =
    { type' : String }


type alias PosterCommentContents =
    { posterSrc : String }


type MediaComment
    = Book (Comment StringCommentContents)
    | Song (Comment (UrlCommentContents TypeCommentContents))
    | Picture (Comment (UrlCommentContents {}))
    | Video (Comment (UrlCommentContents PosterCommentContents))


-- User --

type alias User =
    { name : String }

Using record type extensibility may seem like a great idea, since the goal is to “extend” a type. But if your brain is remotely like mine, reading the above code was a frustrating experience. We’re not modeling a very complex system, but in the interest of keeping code DRY we’ve very quickly ended up in a brain-stack-overflow situation.

So how can we get ourselves out of this mess, and back to a friendly description of our commenting system? We “make a hole” rather than say “this thing is like this thing which is like this thing in these ways.”

Here, we maintain the idea of the “Media Comment,” which protects against accidentally using the wrong comment view for a given media type, but we use the “make a hole” strategy.



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List MediaComment
    }


-- Comment --

type alias Comment a =
    { commenter : User
    , created : Date
    , comment : a
    }


type alias StringCommentContents =
    { comment : String }


type alias UrlCommentContents a b =
    { src : String
    , type' : a
    , posterSrc : b
    }


type MediaComment
    = Book (Comment String)
    | Song (Comment (UrlCommentContents String ()))
    | Picture (Comment (UrlCommentContents () ()))
    | Video (Comment (UrlCommentContents String String))

We can, of course, flatten this all the way back out if we want, but continue to use the “make a hole” strategy:



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List MediaComment
    }


-- Comment --

type alias Comment a b c d =
    { commenter : User
    , created : Date
    , comment : a
    , src : b
    , type' : c
    , posterSrc : d
    }


type MediaComment
    = Book (Comment String () () ())
    | Song (Comment () String String ())
    | Picture (Comment () String () ())
    | Video (Comment () String String String)


-- User --

type alias User =
    { name : String }

This is better than the version using tons of extensibility, but there’s still too much complexity to comfortably keep track of. We can try decoupling our what-kind-of-media-idea from our what-a-comment-looks-like idea:



type alias Model =
    { mediaUrl : String
    , mediaType : Media
    , description : String
    , currentUser : User
    , comments : List Comment
    }


-- Comment --

type alias Comment =
    { commenter : User
    , created : Date
    , comment : Maybe String
    , src : Maybe String
    , type' : Maybe String
    , posterSrc : Maybe String
    }


type Media
    = Book
    | Song
    | Picture
    | Video


-- User --

type alias User =
    { name : String }

Here it becomes more obvious that we’ve been neglecting the information in our model about our actual media type, but leaving that aside, there are a couple of things to notice here. One, it’s the most succinct. Two, information about the necessary and expected shape of a given comment is lost–view code written with this code is going to be full of Maybe.map text model.comment |> Maybe.withDefault (text "oh no we're missing a comment how did this happen???")s. Three, it’s easy to understand what fields exist, but hard to know which fields are expected/mandatory/in use.

A final option for organizing this code: don’t try so hard to be DRY. Have different models/views for working with different comment types, and don’t worry about having overlap in those field names when your record is describing different shapes.



type alias Model =
    { mediaUrl : String
    , description : String
    , currentUser : User
    , comments : List MediaComment
    }


-- Comment --

type alias Comment a =
    { commenter : User
    , created : Date
    , content : a
    }


type MediaComment
    = Book (Comment String)
    | Song (Comment { src : String, songType : String })
    | Picture (Comment { src : String })
    | Video (Comment { src : String, videoType : String, posterSrc : String })


-- User --

type alias User =
    { name : String }

Whatever you decide, do be aware of the outsize impact in complexity/brain-case-space when using extensible records over some other options. Extensibility is for writing flexible functions, not for modeling your types. e.g., for our final example above, we could add another type that would make writing type signatures simpler, without making it harder for us to think about our model:



type alias UrlComment a =
    { a | src : String }

Again, this is a pretty contrived example (for as simple a concept as a comment, it probably makes the most sense to just separate out comment meta data from comment contents). For a complicated web application, though, it’s not unlikely to run into very complex structures on the frontend that can’t be easily broken down into the categories of “shared-shape-meta-data” and “differing-content-data.” My hope here is just that if you find yourself in a situation where your data modeling is getting out of hand across different pages (i.e., tons of different ways of representing very similar but non-identical information), you’ll be able to simplify your models without too much confusion.

case/if..then Expressions

Case expressions are awesome. Elm pattern matching is basically insane in a good way. Using case expressions cleverly can mean cutting down on extraneous/hard-to-follow if/else branches, but using too many case expressions can become hard to deal with.

Let’s make a view with a couple of steps to it. Say we’re making a game. We’re going to have a welcome screen, a game-play screen, and a game-over screen. Refreshing the page is clear cheatery, so let’s not worry about persistence. We’re also not going to worry about game logic. All we care about are the flow steps.

This is our model:



type alias Model =
    { playerName : String
    , moves : List ( Int, Int )
    , boardSize : (Int, Int)
    , gameStep : GameStep
    }

type GameStep
    = Welcome
    | GamePlay Turn
    | GameOver


type Turn
    = Player
    | ComputerPlayer

Let’s start out with a naïve view (naïve doesn’t mean don’t do this! It just means don’t stop your work here). Skipping game logic means there’s not much use to this view, but that should help us to focus in on good patterns to follow and less-good patterns to minimize.



view : Model -> Html Msg
view model =
    div
        []
        [ viewHeader model
        , viewGameBoard model
        ]


viewHeader : Model -> Html Msg
viewHeader model =
    header [] (headerContent model)


headerContent : Model -> List (Html Msg)
headerContent {gameStep, playerName} =
    case gameStep of
        Welcome ->
            [ div [] [ text ("Welcome, " ++ playerName ++ "!") ] ]

        GamePlay turn ->
            case turn of
                Player ->
                    [ div [] [ text ("It's your turn, " ++ playerName ++ "!") ] ]

                ComputerPlayer ->
                    [ div [] [ text "It's the computer's turn. Chillll." ] ]

        GameOver ->
            [ div [] [ text "Game Over!!!" ] ]


viewGameBoard : Model -> Html Msg
viewGameBoard model =
    case model.gameStep of
        Welcome ->
            text ""

        GamePlay turn ->
            buildBoard model.boardSize turn

        GameOver ->
            div [] [ text "This game ended. We're skipping game logic so who knows who won!" ]


buildBoard : (Int, Int) -> Turn -> Html Msg
buildBoard boardSize turn =
    let
        squareStyles =
            case turn of
                Player ->
                    style [ ("border", "1px solid green") ]

                ComputerPlayer ->
                    style [ ("border", "1px solid red") ]

    in
        tbody [] (buildBoardRow boardSize squareStyles)


buildBoardRow : (Int, Int) -> Attribute Msg -> List (Html Msg)
buildBoardRow (boardWidth, boardHeight) squareStyles =
    viewBoardSquare squareStyles
        |> List.repeat boardWidth
        |> List.repeat boardHeight
        |> List.map (tr [])


viewBoardSquare : Attribute Msg -> Html Msg
viewBoardSquare squareAttribute =
    td [ squareAttribute ] [ text "[ ]" ]

Okay, cool! So that works, as long as we’re fine with having an un-updateable model with corresponding view.

But there are a couple of things that are bad. One, we’re repeating case expressions based on game step all over the place. Game state is a very top level concern. We shouldn’t have to re-evaluate what step we’re on all over the place. Another less-than-stellar thing we’re doing is nesting case expressions. It’s harder to follow and, as a rule, not that necessary.

See if you like this view better:



view : Model -> Html Msg
view model =
    div [] (buildBody model)


buildBody : Model -> List (Html Msg)
buildBody {gameStep, playerName, boardSize} =
    case gameStep of
        Welcome ->
            [ viewHeader ("Welcome, " ++ playerName ++ "!") ]

        GamePlay Player ->
            [ viewHeader ("It's your turn, " ++ playerName ++ "!")
            , buildBoard boardSize "green"
            ]

        GamePlay ComputerPlayer ->
            [ viewHeader "It's the computer's turn. Chillll."
            , buildBoard boardSize "red"
            ]

        GameOver ->
            [ viewHeader "Game Over!!!"
            , div [] [ text "This game ended. We're skipping game logic so who knows who won!" ]
            ]


viewHeader : String -> Html Msg
viewHeader headerText =
    header [] [ text headerText ]


buildBoard : (Int, Int) -> String -> Html Msg
buildBoard boardSize boardColor =
    tbody [] (buildBoardRow boardSize boardColor)


buildBoardRow : (Int, Int) -> String -> List (Html Msg)
buildBoardRow (boardWidth, boardHeight) boardColor =
    viewBoardSquare boardColor
        |> List.repeat boardWidth
        |> List.repeat boardHeight
        |> List.map (tr [])


viewBoardSquare : String -> Html Msg
viewBoardSquare boardColor =
    td
        [ style [ ("border", "1px solid " ++ boardColor) ] ]
        [ text "[ ]" ]


It’s more succinct, but more importantly, it has wayyy less branching logic. All the logic over what to show/what not to show is at the top level, so it’s very easy to see what’s going on and what is going to be rendered to the page. This pattern allows for more easily generalizable components. To recap, the two moves we made here were to branch based on state at the top of our view and to eliminate sub-branching logic by making use of Elm’s pattern matching for tuples.

Hopefully, using this pattern will make it easier to extract pieces of your view for reuse, since they’ll end up being dependent on much less of the model than they would otherwise. Compare the board building method of our first try to our second try–the second one just wants to know how big and what color, and it’ll make you a board, while the first would like to know whose turn it is, please and thanks.

Elm Architecture as a Pattern

My final tip: don’t forget that the Elm Architecture is just a bunch of functions that sometimes get called! That is, update is just a function, and calling it recursively is totally fine if you want to.


Tessa Kelly
@t_kelly9
Engineer at NoRedInk


Viewing all articles
Browse latest Browse all 193

Trending Articles