Diving into Elm
The project
I mentionned on the first article of this series that I had an endgame project. I do not wish to reveal too many details about it, but I'm gonna introduce some background.
The project is to build a basic e-commerce website that sells only one product. But the product is a highly customizable one. Through a workflow made of questions with a fixed number of answers, the customer can customize his product. To keep it simple, and frankly to avoid losing the user into a maze of choices, the options you have at one step of the workflow are not affected by previous (or future ...) choices.
In order to complete the project, I need an API and a frontend. To ease the implementation of the frontend as a Single Page Application, I wanted to use a javascript framework, but it was already obsolete. All jokes apart, I chose to learn Elm.
What's Elm ?
Elm is a functionnal language that compiles to javascript. Together with its core libraries, the language allows you to build web applications.
Functionnal languages
A functionnal language does not have variables : it only has functions which are mathematical expressions that compute an output value solely based on input values. The input values cannot be updated by the function. The only effect of applying a function - as opposed to calling a procedure or method - is getting its return value. Forget about writing object oriented stuff such as setSomeField
.
A functionnal language does not have loops or any form of code branching statements. Instead, every language construct returns a value. For example, Elm's if
returns a value based on the truth of a boolean expression. So it's what you'd call a ternary expression in other languages.
The value returned by the if expression could be a function, but it does not mean that the execution path of your program changes. It rather means that the data will flow to this function instead of the one returned by the else
statement (which is mandatory, because you must return a value in any case).
Data flow and execution flow might seem to be the same (and in the end, on the CPU, it all comes down to a sequence of execution steps), but while programming, "data flow" is a better way of thinking functionnal.
Elm's architecture
A Elm webapp is primarily made of three pieces of code :
- The model : model is both used to name the type of data that represents the whole state of your program (ranging from a simple integer for a counter app, to a complex record structure) and its current value (state of the world). The model must contain all the data your application needs to work.
- The update function. It takes two input arguments : the current model (state of your application) and a message (the message is a value of a type that you define, and that represents an external event, such as clicking on a button). From those two values, your function expresses an updated copy of the model. This returned value is the new state of your application.
-
The view function takes the current model as input argument, and from that value it outputs a virtual DOM. The virtual DOM is then used by the framework to display your web page. In the VDOM you can set event attributes such as
onClick
that will dispatch the message you define. This message is one of the input to the update function ... that computes a new model ... that will be used to output a new view.
At that point, if you wish to read some Elm code and have a complete introduction to the language, see the official guide.
Why Elm ?
A co-worker made a presentation about Elm some time ago. Since then, I was curious to use a functionnal language. After all, the basic operation of a webapp is to fetch a "data tree" (JSON) from a server, then transform this data tree to a "view tree" (HTML). This sounds very functionnal and the paradigm seems to fit well.
There are a lot of SPA frameworks and to-javascript transpilers. No matter what they tell you, there are no silver bullet. No solution fits every needs.
So the most important thing when it comes to a tech like Elm is not why but when to use it. In other words, does it fit my project ? I will give my 2 cents based on my short experience, first with the pain points, then with the magical benefits of Elm.
The pain
Note that it's not criticism (despite the title pain may suggest otherwise). I enjoy working with Elm, and I'm merely listing the surprises I had along the way. You may have other surprises. If you're used to pure functionnal programming, you may have no surprise at all.
Think different
First, the most painful thing to achieve is to think different. If, like me, your background is made of procedual and object oriented languages, then thinking purely functionnal is a big shift. I was already familiar with some functionnal techniques, and I'm using them where I think they fit best. But a purely functionnal language like Elm is a whole different beast and bears its learning curve.
I think that, as a beginner, the hardest, yet most important step is to define your app's model. The problem is, not knowing all the features, operators, functions of the language and core libraries and how they all fit together in this new functionnal paradigm, defining the right model for your project is not easy.
To quote the official guide, the best advice I can give is to not plan too much ahead. Before you write any line of code, you usually think that you have a good idea of the model for your app.
You might be used to plan all technical decisions (i.e what unit of code does what) around this model and the expected interactions with it. That's because in many languages and also, depending on your team, refactoring is hard - so you do your best to prevent its need. But growing and refactoring Elm code incrementally is easy.
We'll see later that you just can't shoot yourself in the foot. So do plan a bit the shape of your program, but not too much.
Syntax
Elm's syntax itself, though very simple, first felt very unusual to me. For example, it's not mandatory to specify the types of your function's arguments. But it's highly recommended to hint them with a funny arrow syntax :
myFunction : FirstInputArgumentType -> SecondInputArgumentType -> ReturnType
myFunction x y = x * y
Another surprise lies in the way you apply functions : the syntax uses spaces to separate the arguments. So if one of the arguments is another function, you must wrap it in parenthesis to delimit the inner function's arguments from the outer's :
myFunction1 x y = x * y
myFunction2 x = x * 2
myFunction3 x = myFunction1 x (myFunction2 x)
Updating the world
Then, there was updating records.Records are data structures with named, strongly typed fields. But records are not objects. There are no methods, and definitely no setters. What if you want to update a field ? You use a special syntax that produces a new record (an updated copy of the initial record) :
newModelFunction oldModel = { oldModel | name = newName }
The limitation of this syntax is that you cannot update a nested field :
newModel = { oldModel.customer | name = newName }
This does not work. You can use awkward let
expressions that let you name intermediary expressions :
let
oldCustomer = model.customer
newCustomer = { oldCustomer | name = newName }
in
{ oldModel | customer = newCustomer }
Searching for answsers, I found a better solution. Say you want to update your model's customer name with a new name, in response to a form field update. You will define some kind of update or sub-update function to treat the external update event. This external event will provide you the new value of the field.
With the help of the pipe operator, one can write :
updateName : Model -> String -> Model
updateName oldModel newName =
newName
|> asCustomerNameFor oldModel.customer
|> asCustomerFor oldModel
asCustomerNameFor : Customer -> String -> Customer
asCustomerNameFor customer name = { customer | name = name }
asCustomerFor : Model -> Customer -> Model
asCustomerFor model customer = { model | customer = customer }
What happened ? The |>
pipe operator simply applies the second operand (which must be a function, such as asCustomerNameFor
) with the first operand (newName
) as the last argument for that function. It just kinda flips an expression.
As you can chain and combine them like any other operator, and because the result of the first |>
is a Customer
, we can feed it as the last argument to the second |> asCustomerFor
.
I must admit I like this little pattern very much. It's fun. It looks a bit like object-oriented setters, but more powerful. You can really see the data flow mentionned earlier. Because functions are values, you might even use pipelines to transform or wrap other functions. Be careful not to abuse it though !
Missing pieces ... and humans
Elm is not as famous or widely used compared to the big 3 (Angular, React and Vue). This comes with a few disadvantages :
- Smaller community, smaller support : while the official guide and docs are great, it takes longer than usual to find the answsers that the official docs are missing. Using your favourite search engine for a Elm topic may yield a lot of out-of-topic results.
- Huge projects : I've seen a real world project's sources, and I can't say it was easy to get my bearings inside a big codebase. It was scary. This will get easier with more language experience, but for now I'm unsure about using Elm on big SPAs.
-
Odd code formatting : The code formatting rules suggested by the official docs are odd-looking, especially for data structures literals (lists and record structures).
It goes against every convention I've ever seen for that purpose (human-readable JSON-like formatting), and unlike Go it does not come with a standard code formatter to enforce this easily.
But it's not all bad : Elm also has some great features baked in its core design. Let's take a look at them !
The magic
No runtime errors
Elm's homepage claims that there are no runtime errors in practice. Let's get something clear : you will get errors. But those will be external : the network will break, the browser will apply some sandboxing rule, and so forth.
The key argument is that the Elm compiler will not let you ignore those errors. Any error event must be treated like any other event, leading to a new model (that could be an error state, allowing the view to display an error message).
In fact, the Elm compiler does not allow you to ignore any possibilities. For example, null
does not exist, but the core libraries defines a Maybe
type.
Maybe
can either be of value Nothing
, or of value Just
with a "payload" value of the type you'd expect. If you write an expression that uses a Maybe
anywhere, the compiler will force you to return a value both when the value is nothing and when the value is something. This is known as pattern matching :
myFunction : Maybe String -> String
myFunction x =
case x of
Just str ->
"the string is : " ++ str
Nothing ->
"sorry, but the string does not exist"
function2 = myFunction Nothing
function3 = myFunction (Just "hi")
You can (and you must) define your own variant data types like the Maybe
. A variant type is a type that can hold any of the enumerated, named possible values. Here's a simple example of a "current page" type definition :
type Page
= BuildStep Step
| DeliveryForm Customer
| Payment String
| Loading
| Error
For each possible value, it can hold another value of a defined type (the Step
type of BuildStep Step
above), or nothing. When you use values of those types, the compiler never let you ignore one of the possible value for the type. The standard library uses these variants types everywhere, so it covers all possible error or corner cases.
Divide by zero ? You will get the Infinity number. But hey, before it comes to that, validate your user input so you don't run into a zero divisor in your algorithm.
So, in practice, you have no runtime errors. You may have bugs if you made a mistake implementing the algorithm. But you will have error handling for every external problem such as non-20x HTTP responses. Elm's compiler is the good friend that keeps you away from the self-foot-shooting revolver.
Note that, despite all the efforts put into having friendly error messages, the compiler is not psychic. Don't get me wrong : the compiler outputs clear, readable and friendly errors messages, often with an example of a "smart" possible fix. It's a nice touch because when you start, you get a lot of these.
But because of the parser bias, the fix is not always the one you want to implement. It's all very normal since the compiler does not yet read into your mind. It just feels annoying sometimes to be offered an advice that doesn't fit your goal.
Let me add that compilers and language designs should all behave like Elm. This is because, the point of having computers is to make them do boring stuff we don't want to manage ourselves. Testing a program for its validity/correctness sounds like a computer problem, not a me problem.
Because if you have to do these checks yourself, chances are that you or an unaware coworker will miss something at some point - probably on one of those late-Friday evenings, just before a critical deployment. And if you rely on automatic tests, that means that anything the compiler does not check, you will need to write a test for it. Then test the test (no false positives, no false negatives). Then maintain the test.
My point is : any restriction or convention defined by the language should be checked ahead of time by a compiler or standard, SDK-bundled tool. Period.
Easy refactoring
Thanks to all the checks the compiler does, it's easy to rewrite and grow Elm code. This page states that it's okay to have bigger Elm files than what you're used to. I wrote all my app in a single 450 lines file, which I would not advocate for usually.
I tried to follow Elm's guide advice and avoid planning and splitting files too early. The model and other essential parts changed during implementation - it's a healthy part of the development process -, demanding small changes through the code. Splitting to files usually helps refactoring by scoping responsibility.
But in the case of Elm, those changes were very easy to (re) write, even with a simple text editor with no IDE-like features (I used Sublime Text without any Elm support except for the syntax highlighting). The changes you have to make to grow the code are not subtle enough to require the splitting to modules and defining contracts between them - to a point.
Indeed, because values are immutable (they are not variables), there's no hidden temporal coupling or hard-to-track mutations. The code does not usually lie, but it's especially true for Elm code.
Effortless clean code
Now, what do I mean by clean ? It just means that reading it - once you get the syntax - gives you the answer you need. There's no side effects to chase, check, or think of. The language design and suggested application architecture lead to a beautiful mathematical representation of your program.
Once you get your bearings in this new environment, writing code is a very abstract experience. You define the program as a relationship between different pieces of data and external actions, and it just writes itself. You don't have to think in terms of a sequence of steps to execute and branching flows that achieve your program's goals. You express what's what to what and let the framework do the rest.
Also, there's no HTML-JS-mixing templating language. You write your view using regular Elm syntax and the framework-provided functions. So you're not gonna awkwardly mix JS and HTML with some bizarre templating language. For example, a partial view function looks like :
deliveryForm : Customer -> Html Msg
deliveryForm customer =
div [class "row"] [
div [class "column"] [
form [onSubmit PlaceOrder] [
label [for "firstName"] [text "Prénom"],
input [name "firstName", type_ "text", required True, value customer.firstName, onInput UpdateCustomerFirstName] [],
label [for "lastName"] [text "Nom"],
input [name "lastName", type_ "text", required True, value customer.lastName, onInput UpdateCustomerLastName] [],
label [for "email"] [text "Adresse Email"],
input [name "email", type_ "email", required True, value customer.email, onInput UpdateCustomerEmail] [],
label [for "postal"] [text "Adresse de livraison"],
textarea [name "postal", onInput UpdateCustomerPostal] [text customer.postal],
button [] [text "Commander !"]
]
]
]
This is plain Elm code, in which you can add any amount of logic maths. Including ternary if
's expressions, pattern matching for variant types, the standard list type's map
function (transforming each item in a list to a HTML tag) ... and so forth.
Great debugger
One great hidden feature - hidden in the sense that it's not obviously advertised anywhere, or not as much as it should - is Elm's debugger. It's very simple, but helps you to find your mistakes - the one the compiler could not catch - very easily. It's available via the --debug
command line switch of the compiler.
Once enabled, when you test your webapp on your favorite browser, a small icon at the bottom of the page is available. If you click on it, a popup appears. In the popup, you have the list of all the successive "states of the world" of your app. You see what event and what model lead to which new model, including user clicks, HTTP response, everything.
With that tool, you can trace back your bug to the actual update that lead the model to an unexpected state. Once you know wich event is responsible, you know exactly which piece of code is to be trashed needs fixing.
Expected, modern feature
Finally, any modern language should come bundled with :
- A standard, widely adopted package manager to deal with vendor libraries and somewhat enforces semantic versionning. Check.
- A simple, well self-documented CLI that you can install anywhere easily, and that just works. Check.
- A REPL or a playground, even if the language is compiled. Check.
- A useful
hitchhikerguide to thegalaxylanguage. Check.
Conclusion
Like everything, Elm's no silver bullet. It roots its powers in the functionnal paradigm. From there, a reliable framework let you write your web app easily, while keeping the code clean and easy to maintain. I've seen examples or used other SPA frameworks, and they were awkward to use, mixing JS and HTML in weird ways and not helping much to cope with Javascript's pitfalls.
But because of the lack of support and smaller community - past the great official guide -, the paradigm shift to the functionnal lands, and the increased complexity of interacting with native JS library - while possible - you may not want to introduce Elm at your day job. It all depends on your environment's mindset, on your team and the stakes of your project.
I would only use Elm at the day job if :
- I have confidence in my team's skills, open mind, and curiosity - including myself
- The management or customer trusts you with the risks associated with a less-known, less-popular solution
- The project's user interface is not a huge monster of nested menus and sub-forms, but rather a do-one-thing-well, elegant app, or if you can break it in such smaller pieces.
As for me, I don't regret my choice. It's both fun and reliable to work with Elm, and it fits the needs of my current project.