How to do client-side form validation with Elm
Elm’s static typing and compiler error messages lead to more productivity.
Form validation, taking user entered data and giving them hints about what they need to do, is one of the most common tasks that a web developer encounters. Even though it’s everywhere, it’s easy to forget how nuanced and tricky form validation can be. At the crucial time when a user is signing up for your service, or filling out their profile, it’s easy to irritate a user with incomplete feedback, fields that are deleted upon failed validation, and cryptic error messages.
Form validators are nuanced because every field usually has a different set of error states that could potentially pop up. Emails can be invalid. Passwords need to be a certain length. Terms of service can be, well, not agreed to.
Our problem is that each piece of our form can have different, subtle states. Modeling those states accurately is doubly important because we need to communicate everything back to the user.
Fortunately there is a solution for dealing with this state issue: Elm. Elm is fantastic at modeling state. As we’ll see later on, modeling state well directly translates into the ability to provide a strong user experience.
What is Elm? Elm is a statically typed, purely functional language for the web frontend that compiles to JavaScript. It brings some amazing benefits from the functional and statically typed world such as:
- Ensuring that you will have no exceptions at runtime in practice.
- Not requiring a ton of extra boilerplate in order to get the benefits of static typing. This is due to Elm’s excellent type-inference.
- Making you feel nearly bulletproof when you need to refactor because the compiler is a powerful ally in checking your work.
All of these properties make for a productive workflow in Elm.
First, let’s get you set up! The Elm Guide has installation instructions, though you also have the option of using Ellie, Elm’s live code editor, which is a great way to try out the language without needing to set things up locally. To get you started, here’s what “Hello World” looks like in Elm:
main = text "Hello, World!"
We’ll get into specifics about syntax in a bit, but it’s nice to have an example to start with. The above code would compile to javascript using the Elm compiler, and that javascript would render some text.
Create your own form with Elm
Below, I explain how to create a signup form that needs an email, password, and a checkbox for terms of service. Building this form will show you the basics of how Elm works and how easy it is to build robust solutions with it.
Elm is a language with a platform built in. At a high level, each Elm page has:
- A model
- An update function, which is where that model is updated.
- A view function that renders that model into HTML (using a virtual DOM behind the scenes).
We’ll start by defining our model.
The model
In Elm, we define our model by creating some new types that describe what our data can be. If you’re new to static typing, then talking directly about creating types may seem a little weird, but stay with me. Again, a type just describes what some data looks like and then the compiler ensures that the data always looks as you have described it.
Let’s create a new type, which we’re going to call Model, that represents our form.
type alias Model = { username : String , password : String , confirmedPassword : String , tos : Bool }
In this type we’re essentially saying “When I say I have a Model, I mean that I have a record that has four fields and each of these fields has a type.” So, for example, the value for the username field is a String.
When we describe what data we’re expecting, the compiler can then check our work and give us beautiful compile-time errors. So, for example, let’s say we have a typo in our code; the compiler would give use the following error when we try to compile:
`myModel` does not have a field named `emial`. 4| myModel.emial ^^^^^^^^^^^^^^^ The type of `myModel` is: { confirmedPassword : String , password : String , tos : Bool , email : String } Which does not contain a field named `emial`.
Hint: The record fields do not match up. Maybe you made one of these typos?
email <-> emial
This is very cool because it means the lowly typo isn’t going to cause our developers or our users grief. But let’s move on. Our model describes the data we can expect for our form, but it doesn’t describe the different validation errors that can occur. Let’s upgrade our model to capture validation states.
type alias Model = { email : String , emailValidation : EmailStatus , password : String , passwordValidation : PasswordStatus , confirmedPassword : String , passwordsMatch : Bool , tos : Bool } type EmailStatus = EmptyEmail | ValidEmail | InvalidEmail type PasswordStatus = EmptyPassword | PasswordTooShort | PasswordTooLong | ValidPassword
We’ve created two new types, EmailStatus
and PasswordStatus
, which look a bit different.
These sorts of types are called Union types and they allow us to list all the values that something of that type can take. So, when a value in our code is an EmailStatus
, it can only be one of the following values: EmptyEmail
, ValidEmail
, or InvalidEmail
. It can’t secretly be null, or accidentally be a String; it can only be the values listed. This is impressive because we know the exact shape of our data at all times.
Now that we’ve described the data we’re going to handle in our model, let’s start on our update function. The update function is where all changes to the model are made; you can think of it like a list of every transformation that can happen to our model.
The update function
Before we write the actual update function, we need to create a new type. By convention this type is called Msg and it essentially enumerates all the operations we can perform on our model. Here’s what it looks like for our form:
type Msg = ChangeEmail String | ChangePassword String | ConfirmPassword String | ToggleTOS Bool
Now we can write our actual update function.
update msg model = case msg of ChangeEmail email -> { model | email = email } ChangePassword password -> { model | password = password } ConfirmPassword confirmed -> { model | confirmedPassword = confirmed } ToggleTOS bool -> { model | tos = bool }
This is our first Elm function! It probably looks pretty weird, so let’s talk it through.
- This is a function called update that takes two arguments, msg and model, and returns a new model.
- We start this function with a case statement, which allows us to cover all values that msg can be. In this case, each branch of our case statement is updating a field in our model with some new data coming in.
- Bracket, bar syntax, { model | email = email }, is how you update a field in a record in Elm.
- There is no return statement because Elm is expression-based and implicitly returns the result of each branch.
Elm is statically typed, but we didn’t make any type annotations! This is because Elm has excellent type-inference, which means you get all the fantastic benefits of static types (like compile-time detection of errors), with very little of the boilerplate that is usually associated with static types.
A validate function
Our update function is pretty handy, but we missed one tiny piece: actually doing the validation! Well, we’re in a functional language, what should we use to do that validation? Maybe a function? Definitely a function. Here’s what our validate function looks like.
validate model = let emailStatus = if model.email == "" then EmptyEmail else if String.contains "@" model.email then ValidEmail else InvalidEmail passwordStatus = if String.length model.password < 8 then PasswordTooShort else if String.length model.password > 120 then PasswordTooLong else ValidPassword matching = model.password == model.confirmedPassword ready = (passwordStatus == ValidPassword) && (emailStatus == ValidEmail) && matching in { model | emailValidation = emailStatus , passwordValidation = passwordStatus , passwordsMatch = matching , ready = ready }
In our validate function we’re using a let expression and breaking out each individual part of the validation that we want to do. Again, one of the first things you’ll notice is that Elm is based on expressions rather than statements. This means you can do things like set something equal to an if expression, like the following:
emailStatus = if model.email == “” then EmptyEmail else if String.contains "@" model.email then ValidEmail else Invalid
This is actually pretty cool. emailStatus
is set to EmptyEmail
if the string is empty, ValidEmail
if the string contains an @ sign, and if all else fails, it’s set to InvalidEmail
.
Now that we have a validate function, we need to add it to our update function. So, our ChangeEmail
case now looks like this:
ChangeEmail email -> validate { model | email = email }
We’re updating the email field in our model and passing the resulting model to the validate function. Wait, but where are our parentheses? In Elm, parenthesis are not used to call a function, instead you just state a function name and then provide the arguments separated by spaces. This may seem a little loosey-goosey, but it turns out to be very elegant and easy to read.
Now that we have a validate function, let’s see what a view function looks like.
The Elm view function
The view is a function that takes our model and returns HTML. Here’s what the view function for our form looks like:
view model = form [] [ label [] [ input [ onInput ChangeEmail ] [] , text "email" , emailError model.emailValidation ] , label [] [ input [ onInput ChangePassword ] [] , text "password" , passwordError model.passwordValidation ] , label [] [ input [ onInput ConfirmPassword ] [] , text "confirm password" , matchingError model.matching ] , label [] [ input [ type_ "checkbox", onClick ToggleTOS ] [] , text "accept terms of service" , acceptError model ] , button [ type_ "submit" ] [ text "Sign up!" ] ]
Again this may look pretty weird, but all we’re dealing with are functions (and lists, which are denoted by square brackets).
To start, form is a function:
- That takes a list of attributes and a list of children and returns a representation of HTML.
- It’s the same with label, input, and button, these are all just functions.
We’re able to format things this way because, again, parentheses are not required to call a function, only some whitespace between the arguments.
Our view function can also use the Elm runtime to send Msgs to our update function. That’s what onInput is doing. onInput will send the message ChangeEmail
back to our update function with the String value of the field whenever the input HTML event is fired.
The very last thing to do is to tell the user when there is an error. So, for example, we’re calling the function emailError
with the value of model.emailValidation
and will return some HTML. Here’s what the emailError
function looks like:
emailError status = case status of ValidEmail -> empty InvalidEmail -> div [ class "error" ] [ text "that doesn't look like an email" ]
We define what the HTML should be for each error message. Except there’s one thing, we didn’t cover all our cases. Fortunately the compiler won’t let you make such a silly mistake and we get this nice error message instead:
This case does not have branches for all possibilities.
You need to account for the following values:
EmptyEmail
Add a branch to cover this pattern.
Once we cover all cases, then our app compiles and we’re done! In fact you can check out the code and run it on Ellie, Elm’s online code snippet platform.
Adding more Elm functionality
Of course, this form is still very rudimentary. We’ll probably want to check if an email already exists in our system or maybe add a field that has a different set of error states that need to be communicated to the user.
Fortunately, adding those additional checks is straightforward and you always have the benefit of the compiler when you make a change. Want to add an EmailExists
error state for the email field? We just add another value to the EmailStatus
type and the compiler will tell us all the places in our code that are affected by that change. The fact that Elm’s compiler guards you against having runtime exceptions means you can add features or do a large refactor with strong confidence that you’re not breaking something.
Elm makes frontend programming a blast. The lightweight static typing and beautiful compiler errors make it easy to be very productive. In Elm you’re closer to the dream of being able to open a project, add a feature, and have confidence that you didn’t break anything. In my experience that means I spend much less time debugging cryptic situations, and much more time on the problems that actually matter, like user experience, design, and strong technical features. I recommend checking it out.