Logbook

To find the place under the Elm1

Once again I find myself introducing a new iteration of my personal website. Or rather websites, at this point; I decided it was time to separate a more professional overview from a more personal place. These words are appearing on the latter: a place that is rather more for my opinions and thoughts than just a neutral presentation of facts. This iteration is thus not just a redesign, but an entire rewrite. Which opened an opportunity to explore new languages and frameworks as well. The old site (for multiple iterations already) had been written purely in Scala.js and rendered purely browser-side. My old philosophy was to put as little work on my server as possible, and the idea of pre-rendering what was a 95% static site hadn't occurred to me until much later.

Choices choices

As always with what is basically a greenfield project, there were many options of how things could be approached, what frameworks and languages to use and such. But in the end I narrowed it down fairly quickly to only two options that really appealed to me:

  1. Scala: After all, I have worked with it for years and for all its flaws it still remains one of my favourite languages. Pre-rendering could easily be supported via JVM-side executions of the Scalatags expressions I've been using so heavily over many years in different projects. Whatever actual dynamic execution I needed would easily be served by browser-side Scala.js. Given the simplicity, I wouldn't even expect to need some kind of FRP framework initially. Once it was needed, Laminar was an obvious choice, since I am using it in other projects as well.
  2. Elm: This language had been intriguing me for a while, but I'd had no opportunity to build something with it before. After some research I discovered elm-pages, which was really a perfect fit for what I was trying to do here. Almost everything is statically pre-rendered during deployment, but the same language and types and values can just as well be used on the browser side, where dynamic execution is required. The MVU programming model, where rendering and state changes are well separated, sounded incredibly appealing to me when compared to (F)RP, where state updates and rendering are always convoluted.

Now, every sensible person faced with the choice between something well-known and something new would pick the known option to minimise risk. So, of course, I went the other way. Trying something new is much more exciting. And thanks to LLMs the entry barrier to a new language is much smaller than it used to be. So here we are, on a website primarily written in Elm.

Reflections

The obvious question to ask after such an endeavour is: "Well, how did it turn out?" So this is the part where all things are just my personal opinion. And boy, do I have opinions on this.

The Good

  • The tooling is pretty good. Both the Elm compiler and the Lamdera compiler used by elm-pages are working nicely and produce very helpful error messages when I'm (or the LLM is) snafu:ing. It is a bit of a pain to keep everything in sync. I ended up relying heavily on nix for this and the update procedure is a little daunting/brittle. But overall, a good experience.
  • I continue to adore the MVU model. I admit, the websites are still highly static, so I haven't explored the dynamic parts extensively. I don't want to overindex on this. I may write another article in a year, when I have collected more experience. But: So far so good.

The Bad

The immature ecosystem is a bit of a PITA. I mean, I knew what I was getting myself into, of course. But still. In the two weeks or so I have worked on this, I have fallen over bugs in the page rendering and had to stoop to executing TypeScript for code highlighting. And the Markdown renderer I am using for this article has so many missing features that I am already considering writing my own.

The Ugly

Elm's syntactic choices are...not at all to my liking, to put it mildly. Now many people have repeatedly argued, online and to my face, that syntax is not important. A language is just a tool and it matters what you can do with it, not what it looks like. This is true, to some degree. The outcome is important. But I do spend a lot of time looking at the code. Especially now that I'm just reviewing LLM-generated code and rarely writing my own. I would argue it matters more today that a language reads well than that it's easy to write.

Example

So, what concretely are my gripes with Elm's syntax? Sadly, they are at the core of the language: Function declaration and application.

Take this excerpt from the footnote handler for this article, for example:

mapAccum : (a -> Notes -> Result String ( b, Notes )) -> List a -> Notes -> Result String ( List b, Notes )
mapAccum transform items notes = -- Let's ignore the body.

Quick, tell me, what are the types of the arguments and what's the return type? Say it out loud! How long did it take to puzzle that out? 15s? One almost needs a pencil to draw lines: "This type goes with this argument, that leaves this for this argument, and oh yes, this must be the result type then." Isn't that crazy? This is the heart of a functional language; how can it be so convoluted to determine what goes in and out of a function? I know, I know, this is inherited from Elm's Haskell roots. Is that an excuse? I think not.

The disaster continues with function application. As long as it's on one line, it's reasonable enough:

let result =
    mapAccum transformer items notes
in -- Let's ignore the body.

But once things get longer and more functions are invoked on the way, the readability really suffers:

let result =
    mapAccum
        (\header state -> Ok (extractHeader header, (extractNote header) :: state))
        (calcHeaders input)
        []
in -- Let's ignore the body.

The larger this nesting gets, the harder it becomes to figure out what arguments even belong to which function. Suddenly Scheme/Lisp/Clojure with rainbow brackets don't seem like such a nasty idea by comparison, do they?

It's not just functions either; types follow the same principle. I'm sure you noticed the return type above:

Result String ( List b, Notes )

Type application follows the same space:y layout as function application, and type declarations use whitespace to separate their parameters too. Because visual cues and structure are for simple-minded people, right? Right?

Dreams of better Syntax

Since I have spent many paragraphs complaining about Elm's syntax, it is fair to ask what I would like it to look like instead. And this is, of course, not an easy question. Designing a cohesive language is quite challenging. I have some simple proposals, which I'm sure both Haskell/Elm and Lisp enthusiasts will gasp at in unison, but that work quite well the way my brain is structured at the very least.

Function Declaration

For the same example as above:

fn mapAccum[A, B](
    transform: (A, Notes) -> Result[(B, Notes), String],
    items: List[A],
    notes: Notes,
) -> Result[(List[B], Notes), String] {
    // Let's ignore the body.
}

No more tracing what type goes with which parameter. They are right next to each other. As they should be. "But what if I want the type to be inferred?" you ask. Don't. Types in function signatures are documentation. Never skip them. Don't even think about it! Just because we can devise a type inference system that can do so does not mean we should use it that way.

Furthermore, type variables are declared explicitly early on, and brackets distinguish type arguments from value arguments.

Also, please note the inversion of the error and ok types in the Result type. I don't know who thought that having the error come before the ok value was a good idea. It isn't. If you are reading a language left to right, focus on what is important first. Errors are not the important part of a result type. So they go right.

Function Application

This is much trickier, for me. The obvious answer is of course to just parenthesise it like a normal language:

mapAccum(transform, items, notes)

Easy and straightforward. Could be a valid choice. But then, my brain does love reading things in the "right" order: given X, do Y, optionally using Z. In Elm I am basically a heavy over-user of the |> pipe syntax. In a language of my choosing, I would make that the default way to invoke functions:

(items, notes).mapAccum(transform)

Now making this work requires a little juggling with the above definition of mapAccum, but fundamentally it's a generalisation of how, for example, Rust or Python treat the first argument of an instance method as its receiver. Here, the syntax applies to ordinary functions too, but I have not quite pinned down how to handle multiple arguments in receiver position. Most likely, that would have to be a decision at the declaration site instead.

Example

I've designed some other parts of the "better Elm" language as well, so here is a slightly longer example to revel in or revolt at, depending on the reader's predilection:

pub type alias Item = { name: String, price: Int };
pub type Basket(List[Item]);

pub const EMPTY: Basket = Basket([]);

pub fn add(item: Item, basket: Basket) -> Basket {
	let Basket(items) = basket;
	Basket(item :: items)
}

pub fn total(basket: Basket) -> Int {
	let Basket(items) = basket;
	items.map(|i| i.price).sum
}

Closing Remarks

Despite all the dreaming, I, of course, have no intention of replacing my Elm usage for the time being. Maintaining a functioning transpiler is a bunch of work that really is not justified, given just my misgivings about the syntax. My time would clearly be better spent improving the Elm libraries, for example. But it will remain at the back of my mind whenever I look at a piece of code and try to figure out how I can make it less arcane within the constraints of Elm's existing syntax. And there is always the looming possibility of yet another rewrite in another couple of years.

Footnotes

  1. The title is inspired by this wonderful but otherwise unrelated Suldusk song.