The PET Stack - Part II

Welcome to the second post of the PET Stack series. It comes in the midst of a stay-at-come period for most people around the globe, due to the spreading of the Corona Virus. I sincerely hope everyone is staying safe and healthy.

This is a series of posts about making web applications with the Parcel Bundler, the Elm programming language, which compiles to JavaScript and TailwindCSS, a very convenient CSS framework.

Last time we spoke about setting up Parcel and how to set up development and production builds for our application. We also configured Parcel to compile our Elm code. In this post we'll actually write some Elm code.

Here's a quick recap of what I like about the PET stack:

  • Parcel is a zero-config bundler and dev server.
  • Elm let's you build apps that don't crash at runtime.
  • TailwindCSS is a CSS framework that dramatically minimizes the amount of CSS you'll write.

So this time around, we will continue to build our app using these technologies. I thought it would be fun to write a small game that way. Let's get started and write some Elm code. If you've never worked with Elm before you should still be able to follow this post as I am doing my best to explain things step-by-step, but it can't hurt to check out some basics in the Elm guide.

E is for Elm

Elm is a purely functional language, that really does a lot of things very well. It has a strong type system, a clean (although for many people unfamiliar at first) syntax and pleasant tooling. If you are new to Elm, you'll be surprised, that you'll never run into a null-pointer, or index-out-of-bounds exception, because it's simply not possible in Elm. Elm also comes with a pattern for UI, that has influenced how UI's are built in many ecosystem throughout the programming landscape. Redux, Vue, Hyperapp, Swift-UI, or Kotlin's Kelm just to name a few, are all using a pattern known as The Elm Architecture (TEA). It is a pattern that structures several parts of an application and how they interact.

As this post nicely puts it, everything is built around

  1. Immutable state
  2. One-way data-flow
  3. Managed Side-Effects

I won't start explaining everything into the small details, but if you're interested, you can read through the Elm guide a bit. If you're wondering whether it's worth it then let me tell you, that I definitely became a much better programmer simply by learning Elm. Enough with the praise again. Now, let's just start and see it in action instead.

Building a guessing game with TEA

Let's build a simple guessing game, with the following spec:

  • A number between 0 and 100 is generated
  • you guess a number
  • the program tells you whether it was too small or too high until you finally get it right
  • Everything starts over

Sounds fun doesn't it? 😂 I thought so!

Let's start the dev server in a terminal with npm start and open up src/Main.elm and replace the content with this:

module Main exposing (..)

import Browser
import Html exposing (Html, button, div, h1, h2, input, label, p, text)
import Html.Attributes exposing (class, placeholder, type_)
import Html.Events exposing (onClick, onInput)


main =
    Browser.document
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

init = Debug.todo "Implement me"

update = Debug.todo "Implement me"

view = Debug.todo "Implement me"

We just a added a basic skeleton for TEA and you should see in in your dev server terminal that everything compiles. init initializes our model and update defines how the model changes with incoming messages. If you're familiar with Redux, update would be your reducer and messages your actions. The view function renders our application whenever the model changes. What about the Debug.todo calls here? This tells the compiler not to worry about type checking in that spot. This is useful when you're still developing and want to get things to compile. The app crashes when it is run and hits a Debug.todo though. However, there is no risk, because the compiler will not let us use Debug.todo in production builds, so we will use it now and add implementations for init, update and view step-by-step.

In Elm we can take advantage of the type system to model our domain very precisely.

Let's open up src/Main.elm again and add a few type definitions. First, we need a Model, that can hold the value of an input for guessing, as well as the state of the game we are currently in. We will use records and union types to define it. Records are like objects or structs, that you may probably already know from other languages. You normally use them when you want to express something like: "A user needs a name AND a birthdate AND a cool nickname ...". Union types on the other hand, are are not as common of a language feature than records, but they are very useful. They let you express: "An employee's level can be junior OR professional OR senior ...". Elm has a case expression, which let's us check, which variant of a union type we are actually dealing with. So with that in mind, let's define guesses.

{-| Either we haven't guessed yet, or we have a valid guess. Since users can
enter any string into the input field and not just numbers,
guesses may also be invalid. -}
type Guess
    = NotGuessed
    | Guessed Int
    | InvalidGuess

You may have noticed above, that the Guessed variant carries a kind of payload of type Int. We will use the integer to determine the difference between the number we guessed and the secret number we're trying to guess. Negative means our guess was too small, positive means too big and zero means we guessed correctly. Next up, the game state.

{-| At first the game is in an initial state and needs to
generate a random number. Once that's done the game can be played and we need
to keep track of the secret number and a guess by the player. -}
type GameState
  = Initializing
  | Playing Int Guess

Lastly, we'll define our model as a record, holding a string and the current state.

type alias Model = { guessInput: String, state : GameState }

Everything should compile. If it doesn't for you, check the error message and fix whatever it is. Believe it or not. I believe we just finished the hardest part of our game. We defined all possible states as types and will let Elm's friendly compiler guide us the rest of the way. You'll see what I mean in little bit.

Initializing the model and our first message

To start of the game, we actually need to initialize a model and generate a random number. To generate numbers in Elm we'll install the elm/random package which is part of the Elm language.

npx elm install elm/random

Elm guarantees that functions are always deterministic, so we need to declare generating a random number as a side-effect. This is done with the command type Cmd msg. Commands are returned either from init or update and tell the Elm runtime to perform a side-effect and trigger a Msg with the result once completed. So let's define our Msg type with a single variant for receiving the generated integer.

type Msg =
  GeneratedNumber Int

GeneratedNumber is now actually a constructor function, which takes an integer and produces a value of type Msg. We can use that constructor to create our Cmd for number generation. First, we'll need to import the module we just installed at the top of the file.

import Random

Then we'll define our command using Random.generate.

{-| We want integers between 0 and 100. Also we want the `GeneratedNumber`
message to be triggered when the number has been generated -}
generateSecretNumber : Cmd Msg
generateSecretNumber =
  Random.generate GeneratedNumber (Random.int 0 100)

Now we have everything we need to define our init function. Remove the Debug.todo and make init look like so:

{-| the initial model and number generating side-effect -}
init : () -> (Model, Cmd Msg)
init _ =
  ({ guessInput = "", state = Initializing }, generateSecretNumber)

It's a function, that returns a tuple with an initial model and a command. Our model simply has an empty string, since we have not guessed anything as well as the state set to Initializing. As a command, we'll pass our generateSecretNumber command. Check if everything still compiles.

Next, we need to actually do something with our generated value. We will handle that in our update function. Remove the Debug.todo here too, and make update look like this:

{-| we're handling the message and are returning a new model-command tuple -}
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    GeneratedNumber num ->
       ( { model | state = Playing num NotGuessed }, Cmd.none )

In our model, we update the state to Playing and pass our secret number. We also pass NotGuessed, since we have not received any guesses at this point. As the second component of our Tuple, we pass Cmd.none, which means "No side-effects, please". Everything should compile, but we still don't have a view implemented. Lastly, remove the Debug.todo there and make the view look like this:

{-| We can set the document title and the body in the view. -}
view : Model -> Browser.Document Msg
view model =
  { title = "PET Stack Guessing Game"
  , body =
    [ div []
      [ h1 [] [ text "PET Stack Guessing Game" ]
      , div []
        [ case model.state of
            Initializing ->
              text "Generating Number"

            Playing secret guess ->
              text "Our secret number is " ++ String.fromInt secret
        ]
      ]
    ]
  }

We're using Elm's case expression to check in which state we are. Before we received the secret number, we just display a simple waiting-text. Once we have it, we display the number (only for now though, otherwise it wouldn't be very secret 😅). Make sure everything compiles and open your browser to check out the view. Open the debugger on the bottom right. You can inspect the message that was triggered and see the contents of the model.

Everything is still very basic, but we removed all Debug.todo calls and have a working application with a secret number.

Finishing the game

Usually, I would add all messages one-by-one in an iterative fashion, but for the purpose of this blog post, we will bulk-add them now to get it done. We still need to handle some messages to make the game playable. Here they are:

{-| Add those variants to the Msg type -}
    | GuessChange String
    | GuessSubmitted
    | Restart

Once you've saved, you should see the compiler barking at you. It's complaining about the fact, that we added all these variants to the Msg type, without handling them in the update function. If you're like me, you'll enjoy little reminders like that and the feeling that you cannot produce bugs, just because you forgot to add something. Let's add the cases now in the update function.

update msg model =
    case msg of
        ... -- the branch for GeneratedNumber already exists

        GuessChange str ->
            ( { model | guessInput = str }, Cmd.none )

        GuessSubmitted ->
            case model.state of
                Playing secretNumber _ ->
                    let
                        newGuess =
                            case String.toInt model.guessInput of
                                Just num ->
                                    Guessed (num - secretNumber)

                                Nothing ->
                                    InvalidGuess
                    in
                    ( { model | state = Playing secretNumber newGuess }, Cmd.none )

                _ ->
                    ( model, Cmd.none )

        Restart ->
            ( { model | state = Initializing }, generateSecretNumber )

Let's see whats happening here:

  1. When we receive a GuessChange message we store the string in our model.
  2. When we receive a GuessSubmitted message, we first check if we are in the Playing state, this probably will always be the case but since messages are asynchronous we cannot guarantee it. We pattern match to get the secret number, ignoring things we don't need with the _ wildcard. In a let binding to assign a variable we convert the guessInput into an integer, but have to handle the case, the string cannot be parsed. In Elm, optional values can be represented using the Maybe type, so we pattern match again against Just (we have a number) or Nothing(parsing failed). If we have the number we return Guessed together with the difference of our guessed value and the secret number for comparison later in the view. If we could not parse an integer, we return the InvalidGuess variant. Either way, we assign that value to a newGuess binding and use that to create a new updated Playing state in our model.
  3. When we receive a Restart mesage, we know the game is over. We set the state back to Initializing which resets the game, along with a command to generate a new secret number.

Things should compile, so now we are ready to finish the view. First, let's add two helpers to avoid repetition and make things a bit more readable.

{-| Input and button to submit the guess. We are using `onInput` on the text field
and `onClick` from the Html.Events module to trigger the respective messages which
will cause the update function to run again -}
viewGuessInput : Html Msg
viewGuessInput =
    div []
        [ p [] [ text "What's your guess?" ]
        , input [ type_ "text", onInput GuessChange ] []
        , button [ type_ "button", onClick GuessSubmitted ]
            [ text "Submit"
            ]
        ]


{-| Based on the difference, check if guess was too small, too big or correct.
Render error messages or offer to reset the game if the guess was correct. -}
viewResult : Int -> Html Msg
viewResult difference =
    if difference < 0 then
        div []
            [ p [] [ text "Too Small" ]
            , viewGuessInput
            ]

    else if difference > 0 then
        div []
            [ p [] [ text "Too Big" ]
            , viewGuessInput
            ]

    else
        div []
            [ p [] [ text "Correct" ]
            , button
                [ type_ "button"
                , onClick Restart
                ]
                [ text "start over" ]
            ]

Now let's use the helper functions in the Playing branch of the case statement in our view function, replacing what was previously there.

{-| Three cases to handle.
  1. When there was no guess yet, we render the guess input.
  2. If there was a guess, we call the viewResults helper.
  3. If the guess was invalid, we display a message to the user and also show
     the input field, for him to guess again.
-}
  Playing num guess ->
      case guess of
          NotGuessed ->
              viewGuessInput

          Guessed difference ->
              viewResult difference

          InvalidGuess ->
              div []
                  [ p [] [ text "You didn't enter a valid number" ]
                  , viewGuessInput
                  ]

This is our final game:

Refactoring for the better

Our game works. Try it out. If you have it running locally, play with the debugger a little bit, to see how messages trigger Model changes. The debugger supports time-travel, so as you click through older messages, you'll see the UI change as well.

I wanna talk about the viewResult function. Right now, we are checking our guess and whether it was too small, too big or just right in an if/else expression. That doesn't sound like a reason to refactor, but there's a better way of doing it. Go ahead and delete the else if case in the middle, that checks for guesses that are too big. Everything still compiles, but we just introduced a serious bug into our game. It would be great if we could give the compiler enough information to know, that there's a case that we are no longer handling.

Luckily Elm has such a type built in (it wouldn't be hard to define ourselves either) and it's called Order. It holds info about whether something was less than, greather than or equal to something else. You can see the definition here and it goes along with a compare function.

compare : comparable -> comparable -> Order

Let's refactor our code to use this Order type instead of an integer difference.

First, let's change our Guess type, so that the Guessed variant expects an Order instead of an Int

type Guess =
    ...
    | Guessed Order
    ...

The rest is smooth sailing from here. We will just follow the compilers error messages, until everything compiles again. Let's start with the first.

Screenshot of Elm compiler error message Elm is telling us what to fix.

So let's go to that line and fix it.

  Just num ->
    Guessed (compare num secretNumber) -- use the compare function here

Elm still complains about another place that needs fixing. It's about the viewResult function expecting an Int but being passed an Order. That's correct, we'll need to modify it to accept and handle an Order value instead. So let's do this:

{-| Since `Order` is a union type, we will pattern match using `case` again. -}
viewResult : Order -> Html Msg
viewResult order =
    case order of
        LT ->
            div []
                [ p [] [ text "Too Small" ]
                , viewGuessInput
                ]

        GT ->
            div []
                [ p [] [ text "Too Big" ]
                , viewGuessInput
                ]

        EQ ->
            div []
                [ p [] [ text "Correct" ]
                , button
                    [ type_ "button"
                    , onClick Restart
                    ]
                    [ text "start over" ]
                ]

That's it, the compiler gives us the green light again and everything should be working as it was before, with one difference: Try to remove the second branch that handles the "greater than" case again, just like we did before refactoring. And what do you see?

Elm Compiler case error Elm case expressions need to be exhaustive.

BAM! This time we can't do that, since Elm now knows that there is something unhandled. So with the help of a union type, we strengthened the compilers ability to warn us about unhandled logic. This is a pretty powerful feature that Elm developers tend to use a lot. This is a big reason why Elm doesn't crash at runtime in practice. And I can tell you it's a wonderful feeling to know, that if you forget something, rather than application crashes, you'll get friendly reminders instead. 😊

Recap

In this post, we built on top of our project setup from the first post to build a simple guessing game with Elm (you can see the entire file here). We talked about TEA as a simple UI architecture principle and about modelling our domain with the power of Elm's type system. Also, we refactored using an appropriate type, to make our logic even more robust against accidental code deletion or omission.

So far our app looks pretty bare-bones though. In part III, we are going to add some styles using TailwindCSS.


More Posts