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
- Immutable state
- One-way data-flow
- 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:
- When we receive a
GuessChange
message we store the string in our model. - When we receive a
GuessSubmitted
message, we first check if we are in thePlaying
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 alet
binding to assign a variable we convert theguessInput
into an integer, but have to handle the case, the string cannot be parsed. In Elm, optional values can be represented using theMaybe
type, so we pattern match again againstJust
(we have a number) orNothing
(parsing failed). If we have the number we returnGuessed
together with the difference of our guessed value and the secret number for comparison later in theview
. If we could not parse an integer, we return theInvalidGuess
variant. Either way, we assign that value to anewGuess
binding and use that to create a new updatedPlaying
state in our model. - When we receive a
Restart
mesage, we know the game is over. We set the state back toInitializing
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.
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 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
.