Since writing the Typescript chat server and client, I delved pretty deep into all of the fancy frontend frameworks and got to know them pretty well. They are fun to work in, but there is so much overhead involved, I started craving more similarity to my backend roots. I found some articles on Elm, and it peaked my interest. It looks so much like my favorite language (Haskell) that I wanted to try it out. When I first read the articles, the barrier to entry seemed enormous. There was a HUGE tutorial page for getting even the simplest thing to work in Elm. After a while, articles about using Elm in production started rolling in and the reviews weren’t bad. So I decided to take another look, and thankfully things had improved drastically. The documentation is clear and easy to follow, and I felt like I could get started quickly. So I decided to rewrite the chat client in Elm.
Setup
First you need to install Elm and all the fancy tools that come bundled with it. This page was extermely helpful in everything from installing Elm, to describing each of the command line tools, to describing the language itself. I highly recommend taking a quick peek through all of the articles to get familiar with the language. After installing, let’s write some code!
Dependencies
Dependency management for Elm relies on the elm-package.json
file. This file can be updated manually or using the awesome elm-package
dependency
management tool. Here is what my elm-package.json
looks like:
{
"version": "1.0.0",
"summary": "Chat Client",
"repository": "https://github.com/nprice1/elmChatClient.git",
"license": "BSD3",
"source-directories": [
"."
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/websocket": "1.0.2 <= v < 2.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
This should look very familiar for anybody who has looked at an npm package.json
file. It shows the dependencies and all versions for those
dependencies. Add this file to the root of your project. Run elm-package install
to make sure all the packages are properly installed.
The Code
Now we can get down to actually writing the code. The Elm Guide Architecture page provides a very helpful blueprint for all Elm code that looks like this:
import Html exposing (..)
-- MODEL
type alias Model = { ... }
-- UPDATE
type Msg = Reset | ...
update : Msg -> Model -> Model
update msg model =
case msg of
Reset -> ...
...
-- VIEW
view : Model -> Html Msg
view model =
...
All we need to do is fill in the gaps. The Elm architecture enforces the non-mutatable state ideas that are suggested by the Redux state management pattern. This means we can make some pretty direct comparisons to the original TypeScript Client. The Model will be our Redux state, the Update will be our Reducers, and the View will be our React components. So let’s start filling in this skeleton.
Imports
First let’s import all the libraries we will need. First we import everything we need for our view:
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput, onClick)
The Html
package gives us all of the HTML elements we need in order to actually render our view (div’s, span’s, input, etc.). The Html.Attributes
package
gives us the attributes we need for any of our view elements (like class, value, etc.). We actually only need value
, but I’m just pulling in everything.
We also need some events that we will be firing. For this app we only use text fields and buttons, so we only need the onClick
and onInput
events.
This app will be using Websockets, so we need to make sure to pull that in:
import WebSocket
Finally, we will be dealing with JSON payloads sent to/received from the server, so we need the JSON libraries from Elm to both encode and decode our messages:
import Json.Decode exposing (..)
import Json.Encode exposing (..)
As an extra bonus I also imported the Debug package to make sure I could log to the console in case anything fails:
import Debug exposing (..)
Now that we have all of our imports, we can move on to figuring out what our application state will look like.
Model
The Model will represent our application state. Since we already did this when writing the TypeScript client, let’s look at what we did there:
import { Message as MessageModel } from 'type-script-server/src/models';
export interface ChatState {
messages: MessageModel[],
users: string[]
}
Our application state was pretty simple, we kept track of the current users (well really it was just the current user), and all of the messages that have been sent. We also had a type representing what our Message would look like:
export interface Message {
name: string;
message: string;
}
Since we already have these defined, let’s just move them over to Elm. First we will create a type to represent a message:
type alias Message = {
name : String,
message : String
}
Very straightforward conversion thankfully. Converting the Redux state to our application state is a tiny bit more complicated, though. In our React/Redux application, some of our components had their own internal state (like the current values in a text field) that we now have to keep track of in our core application state. Elm may have a way of separating this out, but since I’m just starting out I’m keeping it all in the same place. So here is our consolidated state:
type alias Model = {
currentUserName: String,
currentMessage: String,
users : List String,
messages : List Message
}
The currentUserName
and currentMessage
are going to keep track of the current data in text fields, and then we have the users and message list like the React/Redux state.
The Elm application expects an init
function that provides the inital Model and the command to run. Our init funciton is pretty simple:
init : (Model, Cmd Msg)
init =
(Model "" "" [] [], Cmd.none)
We provide a model with no username, no message, empty user list, and empty message list. We are also providing no command, because nothing needs to be done to init.
That’s it for the model, now we need to define how that model will be updated.
Updates
In our React/Redux application, our reducers handled the state updates. We had two reducers, one for adding users and one for adding messages. Since our application state is a bit more complicated, we will need a few more. To start, we define the Elm messages that we expect to receive:
type Msg =
UpdateUserName String |
UpdateMessage String |
NewUser |
SendMessage |
ReceiveMessage String
Here is the summary for each of these messages:
- UpdateUserName: This replaces the React view that maintained its own state about the current user name for the login form. This updates the current user name. The parameter passed for this update is the new user name.
- UpdateMessage: Like the UpdateUserName, this replaces the React view state that kept track of the current message before sending it. This updates the current message. The parameter passed to this message is the new message.
- NewUser: This logins a user and updates the user list. Right now this is only fired when the current user logs in, not when other users login. This takes the place of the
ADD_USER
reducer. There is no parameter passed for this message, it uses data from the model. - SendMessage: Fired when the user sends a message. This will use the currentMessage state and send it using the Websocket. In our React/Redux application this was handled by a React component. No parameter is passed for this message, it uses data from the model.
- ReceiveMessage: This is fired when we recieve a message from the WebSocket and we add it to the current list of messages. This takes the place of the
ADD_MESSAGE
reducer. The parameter passed to this message is the stringified JSON payload from the server.
JSON Decoding/Encoding
All of this work hinges on something that it turns out is not as trivial as I was hoping in Elm: JSON parsing. Handling JSON in Elm relies on decoding (JSON to an Elm type) and encoding (Elm type to JSON), so we need to setup some helper methods to do that. First we will define the decoder:
messageDecoder : Decoder Message
messageDecoder = map2 Message (field "name" Json.Decode.string) (field "message" Json.Decode.string)
This function is used to create an Elm Decoder
which can be passed a parameter and turn it into a given Elm type. This decoder says that when handed the
parameter it is expecting a field called "name"
which is a string, and a field called "message"
which is a string and it constructs a Message with those two values.
Now that we have the Decoder
, we can make a function that takes stringified JSON and turns it into a message like so:
jsonToMessage : String -> Message
jsonToMessage messageJson =
case decodeString messageDecoder messageJson of
Ok message -> message
Err err ->
Debug.log ("Failed to decode message" ++ err)
(Message "" "")
This function takes in stringified JSON we get from the chat server and uses the Elm decodeString
function which uses the decoder we created above to finally spit out a Message
.
We also get our first glimpse into some Elm error handling. The decodeString
function returns a Result
, which will either be Ok
with the result, or Err
with an error message.
We are doing some very basic error handling here, logging if it failed and returning an empty message.
Next we need to define our encoder:
messageEncoder : Message -> Json.Encode.Value
messageEncoder message =
Json.Encode.object [ ("name", Json.Encode.string message.name), ("message", Json.Encode.string message.message) ]
This encoder takes in a Message
and tells Elm how to create a JSON object out of it. The result of the encoder is always a Json.Encode.Value
, which can be various things.
In this case we want a JSON object with a string field "name"
and a string field "message"
. Now that we have an encoder function we can create a helper function to get us our JSON:
messageToJson : Message -> String
messageToJson message =
encode 4 (messageEncoder message)
This function just uses the Elm encode
function and uses 4 space indentation for the resulting JSON string.
Now we have everything we need to write our actual Update function:
update : Msg -> Model -> (Model, Cmd Msg)
update msg {currentUserName, currentMessage, users, messages} =
case msg of
UpdateUserName newUserName -> (Model newUserName currentMessage users messages, Cmd.none)
UpdateMessage newMessage -> (Model currentUserName newMessage users messages, Cmd.none)
NewUser -> (Model currentUserName currentMessage (currentUserName :: users) messages, WebSocket.send webSocketAddress (createUserMessageJson "joined the chat" currentUserName))
SendMessage -> (Model currentUserName "" users messages, WebSocket.send webSocketAddress (createUserMessageJson currentMessage currentUserName))
ReceiveMessage userMessage -> (Model currentUserName currentMessage users (jsonToMessage userMessage :: messages), Cmd.none)
The update
function takes in the Msg
that was fired and the current Model
for our application. We MUST handle all of the messages we have defined or else the compiler will complain.
The result of the update function is the updated Model
and any Cmd
that needs to be run. The only Cmd
we have is sending our messages using the Websocket. Here is an explanation for
each of the modifications we make for the messages:
- UpdateUserName: We get handed the new user name as the user types it in the text box, and we update the model with a new
currentUserName
. No commands is required. - UpdateMessage: Like the UpdateUserName, this updates the
currentMessage
and leaves everything else unchanged. No command is required. - NewUser: When we receive this message we add the current user (pulled from the
currentUserName
) to our list of users, and now we actually need to use a command. When a user joins the chat we need to send a message to the server. So, we use theWebSocket.send
function and ourcreateUserMessageJson
helper function defined above to send our JSON payload for a user joining the chat. - SendMessage: First we reset the
currentMessage
field to reset the text input, and we send the JSON payload with the current message (before being erased). - ReceiveMessage: Whenever we receive a message from the server, we first convert it to a
Message
using our decoding helper function, then we add it to ourmessages
list.
NOTE: The {...}
syntax is used to spread values for a type, so we are just expanding the Model
object into individual parameters.
We also have a webSocketAddress
constant defined here, you can see that value in the full code below.
Subscriptions (WebSocket)
We have one way of firing a message from outside of our View, and that is the WebSocket. To make sure we catch when messages are sent we need to create
a subscription for our WebSocket, which just fires a Msg
whenever something is received from the server:
subscriptions : Model -> Sub Msg
subscriptions model =
WebSocket.listen webSocketAddress ReceiveMessage
I don’t pretend to fully understand this model, but my best guess is Elm allows subsriptions for certain events to fire Msg's
. This one is telling Elm
that whenever you receive a message from the given WebSocket, fire the ReceiveMessage
message. The other way to fire Msg's
is to use the view.
View
Time to setup user interaction and actually rendering our app. Our view is very easy in this case:
view : Model -> Html Msg
view model =
case model.users of
[] ->
div []
[
input [ type_ "text", placeholder "User Name", onInput UpdateUserName, Html.Attributes.value model.currentUserName ] [],
button [ onClick NewUser ] [ text "Login" ]
]
_ ->
div []
[
input [ type_ "text", placeholder "Message", onInput UpdateMessage, Html.Attributes.value model.currentMessage ] [],
button [ onClick SendMessage ] [ text "Send Message" ],
div [] (List.map viewMessage (List.reverse model.messages))
]
viewMessage : Message -> Html msg
viewMessage msg =
div [] [ text (msg.name ++ ": " ++ msg.message) ]
We have two views here, one when we don’t have any users and one where we do. When we don’t have users we show a simple login form that
updates the current user name in the model and a button that sends the NewUser
message. After the user logs in we see a simple message form
with a div underneath that displays all of the messages. Each HTML element in Elm has two arrays associated with them. The first array are the
attributes, and the second is the content. Most of what we are doing here is related to attributes, the important ones being what we are using
for the onInput
, onClick
, and Html.Attributes.value
entries. The onInput
and onClick
attributes both point to a Msg
that will be fired
with the event. As a user inputs into the text box for the login form, for example, the UpdateUserName
Msg
will be fired with the current value
in the text box, allowing us to keep track of the current username with all input. Then, the onClick
for our button will send the NewUser
Msg
that sends the message to the server informing a user has logged in. The Html.Attributes.value
points to the value in the model so that when we
update the model (like clearing out the current message), it is reflected in the View.
Putting it All Together
The last thing we need to do is tell Elm all of the pieces of our application. We do that with a Program
that describes the model, view, updates, and subscriptions.
Here is what our Program
looks like:
main : Program Never Model Msg
main =
Html.program
{
init = init,
view = view,
update = update,
subscriptions = subscriptions
}
Each of these values correspond to what we added throughout this tutorial. Here is our full source code:
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput, onClick)
import WebSocket
import Json.Decode exposing (..)
import Json.Encode exposing (..)
import Debug exposing (..)
webSocketAddress : String
webSocketAddress = "ws://localhost:3000"
main : Program Never Model Msg
main =
Html.program
{
init = init,
view = view,
update = update,
subscriptions = subscriptions
}
-- MODEL
type alias Message = {
name : String,
message : String
}
type alias Model = {
currentUserName: String,
currentMessage: String,
users : List String,
messages : List Message
}
init : (Model, Cmd Msg)
init =
(Model "" "" [] [], Cmd.none)
-- UPDATE
type Msg =
UpdateUserName String |
UpdateMessage String |
NewUser |
SendMessage |
ReceiveMessage String
update : Msg -> Model -> (Model, Cmd Msg)
update msg {currentUserName, currentMessage, users, messages} =
case msg of
UpdateUserName newUserName -> (Model newUserName currentMessage users messages, Cmd.none)
UpdateMessage newMessage -> (Model currentUserName newMessage users messages, Cmd.none)
NewUser -> (Model currentUserName currentMessage (currentUserName :: users) messages, WebSocket.send webSocketAddress (createUserMessageJson "joined the chat" currentUserName))
SendMessage -> (Model currentUserName "" users messages, WebSocket.send webSocketAddress (createUserMessageJson currentMessage currentUserName))
ReceiveMessage userMessage -> (Model currentUserName currentMessage users (jsonToMessage userMessage :: messages), Cmd.none)
createUserMessageJson : String -> String -> String
createUserMessageJson message currentUserName =
messageToJson (Message currentUserName message)
messageDecoder : Decoder Message
messageDecoder = map2 Message (field "name" Json.Decode.string) (field "message" Json.Decode.string)
messageEncoder : Message -> Json.Encode.Value
messageEncoder message =
Json.Encode.object [ ("name", Json.Encode.string message.name), ("message", Json.Encode.string message.message) ]
jsonToMessage : String -> Message
jsonToMessage messageJson =
case decodeString messageDecoder messageJson of
Ok message -> message
Err err ->
Debug.log ("Failed to decode message" ++ err)
(Message "" "")
messageToJson : Message -> String
messageToJson message =
encode 4 (messageEncoder message)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
WebSocket.listen webSocketAddress ReceiveMessage
-- VIEW
view : Model -> Html Msg
view model =
case model.users of
[] ->
div []
[
input [ type_ "text", placeholder "User Name", onInput UpdateUserName, Html.Attributes.value model.currentUserName ] [],
button [ onClick NewUser ] [ text "Login" ]
]
_ ->
div []
[
input [ type_ "text", placeholder "Message", onInput UpdateMessage, Html.Attributes.value model.currentMessage ] [],
button [ onClick SendMessage ] [ text "Send Message" ],
div [] (List.map viewMessage (List.reverse model.messages))
]
viewMessage : Message -> Html msg
viewMessage msg =
div [] [ text (msg.name ++ ": " ++ msg.message) ]
Now we can finally run it.
Running the Client
In order to run this, you need to have the TypeScript Chat Server installed and running.
Next, in the directory with your Elm Chat Client you can run elm-reactor
. This will start a web server listening on port 8000.
So when you visit http://localhost:8000/App.elm you should see an input field and a button (the login form). After entering a username and logging in,
you should see a similar form that allows inputting messages. You can open a new tab in your browser at the same address and start chatting.
Improvements
This chat client is pretty bare bones, shocking I know. There are a ton of things that can be done to improve how it works, but here are the things that jump out the most in my mind.
- UI: The chat app looks like an MIT professors blog, bare bones HTML. Add some CSS to make it look like an actual application.
- User List: Our user list state is meant to keep track of all users currently logged in, but right now it only tracks the current user. Getting this
to actually keep track of users would be a nice touch. You can parse the nasty
"joined the chat"
message to get the user name, but the preferred approach would be to have the WebSocket send different messages for logging in and sending messages. If you wanted the real list of users, you would also need some persistence in the server. - Separate the State: A nice benefit of our React/Redux application was different React components can maintain there own state without polluting the
core application state. Right now the
currentUserName
andcurrentMessage
fields are cruft we hopefully don’t need to have in our core model. Elm hopefully supports breaking the state into individual pieces, otherwise the state would get bonkers pretty fast.
Conclusion
All in all I really enjoyed writing this. Elm is a pleasant language and I can’t help but love anything that takes some ideals from Haskell. The architecture makes sense, and I love when programs can enforce “good” practices since I can safely admit that I, as a human programmer, can be super dumb sometimes. However, I do have two small complaints, both of which would only really be a problem at scale and may already have solutions I am unaware of:
- JSON Handling is Annoying. We are dealing with very simple JSON objects here and it is already getting bloated, I can’t imagine what it would look like with large nested JSON objects.
- Views could get gnarly. With large complex views, I’m not sure how readable or maintainable this representation of HTML will be. As long as you can modularize the view easily it wouldn’t be so bad, but this is just asking for mega 1000 line don’t-look-directly-at-it-you’ll-go-blind views.
In case it wasn’t obvious I am far from an expert on Elm and I make some assumptions in this post. If you notice anything incorrect please let me know in the comments!