I have been looking into compile-to-JS languages off late and Elm, in particular. One thing that keeps coming up in several pieces of literature about Elm is the idea of rethinking best practices. This article is my take away and notes from a talk by Jamison Dance in 2016 React Conf titled Rethinking All Practices: Building Applications in Elm.
The whole video talks about why we should consider building applications in Elm. It's very interesting to go back in time and see these talks and how the language adoption and ecosystem have evolved since then.
Why Elm
The argument for Elm (and other languages that with stricter type systems) always starts with the common error seen in most JS code bases.
undefined is not a function
Take the following code example from the talk:
function foo(num) {
if (num > 10) {
return 'demo code is the best code';
}
}
console.log(foo(1).toUpperCase());
// Uncaught TypeError: Cannot read property 'toUpperCase' of undefined
The big underlying problem in this code is two-fold.
- Implicit return of
undefined
when theif
condition is not met - Lack of type checker that is able to look at the call to
toUpperCase()
and determine that it could potentially be called on something that's not a string
Bug tracking as a solution
The way we have learned to deal with such a fundamental issue in the language is to track bugs using software like Sentry or Rollbar. However, the ideal situation would be to use computers to prevent these errors and not discover them in production AFTER shipping code to users.Tools like eslint, flow (typescript wasn't spoken about in this talk) can catch these issues while authoring code.
Gradual type systems
As much as tools like Flow and Typescript help, there is a fundamental tradeoff they had to make. These type systems are designed to be gradual so it's easier to adopt and convert codebases slowly over to the new setup.
However, gradual type systems are optional by design. You can turn them off. It's a side effect of the design choice to allow users to optionally adopt it. Now that we have widespread adoption, this is a problem because you can have Typescript and still not have type safety. This means there is no guarantee and there is an escape hatch that's easy to use, especially when you're in a pinch.
Elm, on the other hand, guarantees no runtime errors because you cannot opt out of the type system.
Elm's compiler in action
Let's look at the same function in Elm
foo num =
if num > 10 then "demo code is the best code"
results in the following error
Error: Compiler process exited with error Compilation failed
Compiling ...-- UNFINISHED IF -------------------------------------------------- src/Main.elm
I was expecting to see an `else` branch after this:
47| if num > 10 then "demo code is the best code"
^
I know what to do when the condition is True, but what happens when it is False?
Add an else branch to handle that scenario!
Detected problems in 1 module.
If you add an else case and attempt to return null
foo num =
if num > 10 then "demo code is the best code"
else null
Elm complains because there is no such thing as null in the language.
Error: Compiler process exited with error Compilation failed
Compiling ...-- NAMING ERROR --------------------------------------------------- src/Main.elm
I cannot find a `null` variable:
52| null
These names seem close though:
num
not
abs
acos
Hint: Read <https://elm-lang.org/0.19.1/imports> to see how `import`
declarations work in Elm.
Changing to use the maybe type, this code becomes:
foo : Int -> Maybe String
foo num =
if num > 10 then
Just "demo code is the best code"
else
Nothing
The type signature foo : Int -> Maybe String
is optional but there's another talk I saw where we go into the benefit of having the type signature there and having it separate from the parameter naming.
Here's another function that calls the previous function foo and runs a switch statement on the possible return values
bar : Int -> String
bar num =
case foo num of
Just str ->
String.toUpper str
Notice the problem? We have skipped the else case from the original function here as well. But this is harder to detect because its one level of abstraction away from the original function. The elm compiler throws the following output:
Error: Compiler process exited with error Compilation failed
Compiling ...-- MISSING PATTERNS ----------------------------------------------- src/Main.elm
This `case` does not have branches for all possibilities:
57|> case foo num of
58|> Just str ->
59|> String.toUpper str
Missing possibilities include:
Nothing
I would have to crash if I saw one of those. Add branches for them!
Hint: If you want to write the code for each branch later, use `Debug.todo` as a
placeholder. Read <https://elm-lang.org/0.19.1/missing-patterns> for more
guidance on this workflow.
The correct version of this function is below
bar : Int -> String
bar num =
case foo num of
Just str ->
String.toUpper str
Nothing ->
"Welp"
This is a peek into the friendly nature of error messages as well as the exhaustive nature of the compiler combined with an expressive type system. The benefit is we don't run into cases where the programmer forgot to account for a non "happy-path" use case.
Designed for maintainability
As applications grow, they become harder to maintain in Javascript. The underlying problem is there are patterns in Javascript but nothing that force you to adhere to them. The React model is great but you don't have to adhere to it.
Elm architecture is very similar to how you would build a react / redux application (mainly because Redux was inspired by Elm). It's a new language, there's new syntax and a lot of learning when you begin but the good thing is "how do I build stuff" has already been figured out. There is only one pattern to build applications and that constraint gives you the freedom to focus on solving the problem than spending time picking a pattern or ensuring the entire team sticks to the same pattern, thereby making maintainability a breeze.
Pure functions
Elm only has stateless functions and the type system will force you to write stateless functions. You cannot introduce side effects unintentionally. If you have been working in pure functions, you know they are easier to test, reason about and reuse. They are composable and maintenance becomes a lot easier in the long run.
Immutable data structures
Elm only has immutable data and you cannot mutate variables. This makes it a lot easier than in Javascript where immutability is optional and a lot of times, you depend on a library like immer to achieve the effect. There is, however, some boilerplate involved. Even when these libraries are used, it is inevitable that someone doesn't adhere to the pattern because they are new or unaware or in a hurry. I have personally seen this happen in teams where you scale from a handful of people to several hundred people over the years, the codebase slowly gets worse over time.
That's the difference between having the freedom to follow any paradigm in Javascript vs only being able to program in one model. The language is designed to provide you this benefit without having to rely on conventions or additional tooling to enforce this aspect. Additionally, there is no fatigue in trying to figure out how to do things the right way since there is only one way to do things in the language. This brings us to the core differentiation between JS and Elm - constraints vs guidelines.
Constraints can guide you towards better design
JS prefers less constraints and values freedom. Elm frees you up from making these decisions, so you can focus on your problem domain instead. This is not to say there is no downsides to elm.
Downsides of Elm
Elm does best when it runs everything, which is hard to achieve in real world projects.
It is still early days and there was no server side rendering as of this talk. However, there're some chatter about prerendering and server rendering frameworks that I haven't looked into yet.
Production readiness
A lot of time has passed since this talk and Elm is most certainly production ready right now. But the thing I got out of this section of the talk is a confirmation of a sentiment I've heard in multiple places:
Production readiness is not binary. It is more of a sliding scale.
The talk ended with how the JS community seemed to be evolving towards what Elm already is.
Further reading
- Listen to JS Jabber podcast episode Elm with Evan Czaplicki and Richard Feldman
- Watch this talk Understanding Style by Matthew Griffith
- Learn more about the state of server rendering in Elm and the Spades framework