My Elm Experience2022-01-20
I’ve been using Elm since about one year back now. At the start of 2021 I began working with a client that had an existing Elm frontend code base and an early stage backend built in Elixir and Phoenix. This post will be about my experiences running face first into Elm. I’ll be vague on some details as I can’t share too much about the specific client and code base. I’ll be much less vague about Elm itself.
To put my thoughts into context. I know an okay amount of Functional Programming (FP). I know it mainly from the Elixir perspective, so high level, dynamic. Not a lot of focus on types and much more focus on run-time than compile-time. Overall my experience as a programmer trends heavily to high-level and dynamic (I went Perl, PHP, Python with some JS mixed in). I do have some experience poking around more type-heavy languages like ActionScript (for Flash, I think they got really OOP and typed around 4), some C# with .Net, some Java for Android and probably some I’m forgetting. But mainly dynamic stuff.
For this project I was mainly brought in to bring the Phoenix code up to snuff and get it production-ready, secure and at all deployable. One of the big things I discovered was just how prototype-level the server application was at this point. I’m not privy to what the plan was originally, the original team is not involved any more, but the charitable read is that the server was a proof of concept when I was brought in. You can envision the frontend as a domain-specific multi-user collaborative document editor of some kind. It does a bunch of fancy UI and updates a data structure. The way it was being persisted was essentially by packing the document state into a big chunk of JSON and sending it from Elm over Phoenix Channels as a “document_change” event. This event would then be distributed to every other user connected and also saved to file. File writes were serialized using a GenServer which took the new data and overwrote the file. A user checks a checkbox, they update their local document state, the ship that document to the server, the server writes it to a file and sends it to everyone else.
You do not have to have read Designing Data-Intensive Applications by Martin Kleppmann to know that this can cause issues. As it happens I was in the midst of reading the book though which made it all the more amusing. So if Bob and Ellen both check different checkboxes at essentially the same time only one change will actually be kept. The last one. And state will flicker back and forth as a result. To make it much more reliable I would need to break apart the individual operations and make sure that they could happen separately. We could deal with some contention if multiple people changed the same thing, but we didn’t want someone renaming the document clobbering another persons carefully written note and vice versa. So we didn’t need CRDTs or OT for full on collaboration. But we did need more granularity.
I figured out a way to structure this on the Phoenix and Elixir side rather quickly. I normalized the data model with Ecto and backed it with Postgres. Nothing fancy, very straightforward, typical relational database fare. Then I spelunked the Elm codebase to find which change operations actually occurred on this document. That was well structured and I found a type that described all of them. So I stubbed all of them as command events in the Phoenix Channel and made them all throw exceptions until implemented. The concept was that a command could be sent in, we’d update the data and then Phoenix would send out that it had committed a command for all connected users. Since the logic for applying the changes already existed in the Elm code on the client side I’d essentially be able to re-use that for updates from other users. I’d have to mirror it correctly on the server but that seemed tractable (and was, eventually). If a command failed for some reason it would get a rejection event in response and wouldn’t be broadcast to other users.
So I had to create JSON commands from these Elm types, produce some new Phoenix Channel events and receive some new ones as well. This seems like it should be very achievable. Maybe I’d even be able to be clever about it.
I spent two weeks more than I expected to get this done. Fixed bid though, don’t worry about my client, they did fine ;)
To get it done I had to accept a few things as undeniable truths (within Elm):
- The Compiler is Right
- The VS Code plugin is sometimes right, sometimes wrong and often breaks, when in doubt run the compiler
- Being Clever is Forbidden
I was trying to be clever a fair bit and Elm fought me all the way. Elm does not allow you to do side effects, it doesn’t take kindly to unspecified behavior and it doesn’t really do generic code very much. Especially not with your JSON.
So I had an easy time finding the changes I wanted to turn into commands. Figuring out how I could hook into it gracefully and defer application of the change until committed was significantly more difficult. A big thing was my complete inexperience reading the language and just how resistant it was to me hacking my way to success.
I was recommended the Elm Guide which is a good starting point for getting going with Elm. It is not a good starting point for getting deep into an existing, large, Elm codebase. Or it wasn’t enough for my needs at least. There were lots of
|> which felt familiar,
<| which I could mostly grasp,
\foo -> which I’d never seen and so much
andThen for both
Maybe. I had to get into “the continuation passing style” this particular code used for certain JSON operations, some specialty Monad things they’d implemented because apparently
Result was not sufficient and some syntax I’m still not sure about.
There were choices made in this code way back that I’m not really sold on. I can’t get into the details but I think that helped make things a bit extra dense and inscrutable at times. However I think the nature of Elm is that it is simple, much like I’ve heard Go is simple, and the combos of strict, simple and explicit tends to lead to a lot of code. Not verbose code, Elm is quite lean in many ways, sometimes even terse, but you end up producing a lot of code. Since there are no side-effects to speak of the code is rather easy to grok and even change parts of in isolation. At least once you can read the syntax and common flow well enough. Getting into it I felt like I was the victim of too clever programming. Some of it was/is but much of it was just me being entirely not used to living with
Maybe. There is no null except the very explicit
Maybe and there is no way to disregard a failure without actually properly dealing with (or defaulting on) a
Result. There are very limited options for saying “I’ll do this part later”, “this will never happen” or “I don’t care about it”. You application has to compile.
This is all very unusual to me.
It is also very powerful. I’ve discussed types and typesystems on assorted BEAM Radio episodes and I don’t want Elixir to be like this. I’ve also discussed types specifically in this Regular Programming episode. I’ve been very resistant. And I mostly still am. I will say that Elm makes an interesting case though. A pragmatic use of strict Functional Programming with all the types, all the time. I bet it does a cleaner job than TypeScript + React/Vue/Angular for the same domain. With some asterisks where those frameworks have a faster path to doing dirty things and Elm requires that you formalize your dirty boundaries. But that’s what people want by switching to TypeScript right? Anyway, Elm has definitely let me see types more for the tool they are and what they can offer.
I still don’t agree with people who go all in on a dynamic language and go “this is great!” and shortly after “we need types!” because I think trade-offs are a thing. It feels close to people who discuss writing tests or test coverage as undeniable truths of things that must be done otherwise software is objectively bad. Or people that want a rewrite in Rust. Or that consider Erlang/OTP irrelevant now because they could “just be reimplemented in Go/Zig/Rust/Java/their-preferred-language”.
The most satisfying thing with Elm is when the frustrating strictness lets up because you’ve satisfied the compiler and logic itself, suddenly things work. Once I’d bitten the bullet and implemented a ton of decoders and encoders, handled
Result more than I ever wanted, then suddenly the parts I’d implemented worked. And then it was a matter of repeating through all the different commands. Fixing some nuanced behaviors. And it worked really well.
Since this frustrating, learning-heavy, start I’ve become fairly fluent at reading and writing Elm. I’ve built a number of frontend-centric features for this product, revamped UI and just written a ton of Elm. It is still the case that the amount of code can become quite unwieldy, it grows vertically rather quickly. That is unless you refactor it heavily to flow in pipelines and if you do that you get a fast proliferation of functions which explodes your understanding of things across many functions. And still a lot of vertical text, just elsewhere. So designing software and making the right trade-offs for source code that is easy to work with remains a challenge. It has a particular flavor in Elm but it is the same problem as always.
I’ve also introduced four developers that are fairly new to Elixir entirely freshly into Elm. They definitely found the length of a given module frustrating as well. I will say that their process of “this is inscrutable” over into “I guess I just do this and that, and then this” was very similar to mine. That put my own frustration into perspective and has probably eased my view on both learning the language by immersion and the code base itself.
I bet we’d all have had an easier time learning Elm on a green-field project. Now we had to learn all of it at once to be able to read what we were doing. The code already contained everything from the simple to the intermediate to the advanced but nothing looks like the basic examples from the guide because everything is more nuanced in the messy reality of an actual product. We all had to absorb all of it or we’d have large gaps in our understanding. There are parts I still haven’t dug into to be fair but I know how this code works and can work on it comfortably.
The compiler is very helpful almost all the time. It can really indicate very well what part of the code is currently incorrect. Now that can still be confusing because types and type aliases can get wild.
The VS Code dev tools have given me so many parsing errors, crashes, delays and mis-indicated errors that I essentially only use it as a soft indicator, my dev server tells me if there is a real issue. Unfortunately the parsing errors often break go-to definition and find references which are incredibly powerful in navigating this codebase. I get that it is an open source community effort and it is super helpful but I can’t say it works very well on this codebase. Large files and deep nesting seem to be issues on their tracker already so hopefully it will improve.
The best editor tip I can give when your files get long and you need to work in multiple locations is to split your editor so you are actually editing the same file in multiple panes/buffers. That’s been critical for me and I think the pattern of long files is pretty common in Elm, I’d be surprised otherwise.
A thing I missed is a quick reference of all operators and other assorted syntax where I could just look up “what does this mean?”. I usually found it on some nice helpful post someone had put together, probably from the Elm team. But I haven’t found something I’d call a reference manual to the language. There’s the guide which is useful but not very comprehensive. And then the documentation pages for the standard library and community packages are quite fine. And over time I’ve bridged most of the missing pieces.
A thing I like is that much like Elixir the Elm community is smaller than mainstream langs. This means it churns slowly, it also seems to have the quality of Elixir I like where many libraries don’t change much. If it worked three months ago odds are it still works. Maybe there’s something new you’d get by updating, but probably you won’t need to.
All in all. Elm has been an incredibly useful learning experience to me. It also shows just how tight and neat something can be when it is heavily designed for a tight purpose. While Elm concepts could move outside the frontend space it feels like a DSL for building web frontend apps. Capture interactions, update state, render a representation of the state. Tightly and reliably. Making web UI entirely deterministic.
However, it also highlights that this isn’t entirely true. There are a lot of odd kludges you run into to handle some of the nuances of web development. There are things Elm doesn’t know about and handling some things via Ports is the least convenient part of Elm. But it is also the trade-off that makes the magic happen. Web application development is an inherently complex thing and there is cost to simplifying parts of it. I think Elm does a good job with that tradeoff. I haven’t seen it entirely fall down yet.
- Easy: Making an interactive UI with many forms of interactivity that reacts in near-realtime and behaves consistently.
- Intermediate: Rendering complex SVG UIs that interact heavily with mouse events from a complex state.
- Unreasonably complicated: Focusing a text input on first render.
You win some, you lose some. Web development.
If I’m very wrong about Elm, or very right, or you have something to add or discuss you can reach me at firstname.lastname@example.org or via Twitter as @lawik. If you want more of my writing my newsletter is weekly and gets into a lot of nuanced tech career stuff. Signup below, it don’t track.