Josh Kingsley

8 Years of Clojure

It was ClojureScript that first got me into Clojure, when a friend showed me his project using Figwheel and Reagent over coffee in Borough Market. This was back in the day when hot reloading didn't really exist for JavaScript, and React was still using class components. Being able to modify simple functions returning HTML as data (no JSX!), and have the UI update without resetting its state, blew my mind. It still does!

I became enamored with the REPL-driven workflow, the functional-first pragmatism, and the simplicity and depth of the ideas that went into the language's design. For the better part of my career now, I've been lucky enough to get paid to build software using my favorite programming language. I've used it almost exclusively for all of my professional and personal projects.

After 8 years of using Clojure in anger, I've grown in both expertise and appreciation for it. As a niche language, it's helped me to find stable work I enjoy, within teams who share my beliefs about what matters in the craft of software. It's only continued to reward me as I've learned it more deeply. Countless times, it's given me enormous leverage to deliver quickly on large, complex, full-stack features, and to onboard confidently to new codebases with radically different architectural paradigms. I'd consider myself an expert-level Clojure user.

But I've also accumulated some criticisms about Clojure "as she is wrote." Every programming language has its tradeoffs, and I want to share the pain points that would affect my choice of whether or not to use Clojure for future projects. This post is part critique, part personal reflection for me on what's next.

ClojureScript

In 2016, when my friend showed me his project, ClojureScript had far and away the best tooling for frontend web development. It really was magical saving a file, seeing the little spinning Clojure logo in the corner of the webpage, and having it update in place. Figwheel set the standard for rapid frontend iteration and inspired the changes in the JavaScript ecosystem that were to come.

Nowadays, hot module replacement is table stakes and works without configuration. ES modules have made that simpler, and they've also simplified publishing libraries and brought JS/TS the kind of tree-shaking optimizations that the Google Closure compiler brought to ClojureScript.

I no longer feel so enthusiastic about choosing ClojureScript, in part because the tooling and the language have not caught up. Something like Vite is much faster, and the Google Closure compiler and library are showing their age. ClojureScript still feels like you're targeting ES2015. And with the web ecosystem moving as quickly as it does, I've found that a lot of frontend code ends up being interop with native browser APIs and NPM packages. Writing interop code is clumsy: it sucks working with TypeScript-typed APIs without the linting and editor completions. It's possible to write JavaScript modules and import them directly, but the story is not as fluid as you'd like. Unfortunately, this is all inherent to coding for the web in a language which is not a first-class citizen.

ClojureScript can still provide a lot of leverage for teams already using Clojure, especially when sharing code. Reader conditionals make it particularly ergonomic to do so. But the JavaScript ecosystem is really compelling now, especially when building on the cutting edge. The web is still married to JavaScript, for better or worse. I'm focusing on using TypeScript in future projects. And I'm getting a lot of mileage from vanilla Web Components - it's refreshing seeing how much can be done without heavy frameworks.

Impedance mismatch

An observation I've made, more integral to the language itself, is that Clojure heavily favors a style of coding which creates impedance mismatch when dealing with other systems. Most well-written Clojure codebases end up with an opinionated core mapping to a more interoperable outer API layer. The data at the edges looks more like JSON, and the interior is modeled as mostly flat, open maps, with namespaced keys:

{:user/id 1
 :user/name "Alice"
 :address/city "Toronto"
 :address/zip "123456"}

The above is conceptually an open set of keys which are related to the same entity, rather than a static shape, like the kind of closed, nested objects you'd write in TypeScript:

{ id: 1, name: "Alice", address: { city: "Toronto", zip: "123456" } }

Clojure is a dynamically typed language, and namespaced keywords are a big part of how it makes complex data manageable in a large codebase. A namespaced keyword in a map, like a namespaced variable, has a global meaning (which can be defined using clojure.spec). In Clojure, you don't define a user parameter to a function as a static shape: you define the "herd" of keys that your function needs to do its job.

If you're lucky enough to be using a Clojure-native database like Datomic, then this idiomatic style comes more naturally. Generative, property-based testing gives you a lot of the assurance of static types, and the data model is simple and robust to large changes where static types might be complex and brittle. Functions take what they need and pass through the rest.

However, I've worked with a lot of code that fought against this style. At Nosco, we were using MongoDB and GraphQL, which meant passing around a lot of JSON-shaped, opaque maps, without namespaced keys. The best way to deal with data like this is to spec it using Malli, which provides some nice tools for parsing and transforming data on the edges. But it's already harder to refactor and read code like this. A nested lookup like (-> user :address :city) is already more brittle than a single key lookup like (:address/city user). And the more of these nested lookups that accumulate across the codebase, the more you're going to struggle to find them all. You're already missing types.

The Platonic ideal of a language is usually not the one we end up writing in practice: everyone on the team brings their own experience, expedience trumps engineering, and complex problems bring up edge cases that weren't well designed for. We've all written and worked with unidiomatic code. Clojure gives disciplined teams a strong set of tools for building extraordinarily robust systems. But I've learned to concede that a static type system can often help to bring assurance to the bottom end of a codebase and to its edges.

Ecosystem

The Clojure ecosystem is vibrant, stable, and high quality. Having worked with it closely, I've developed some thoughts on its subtleties.

clojure.spec is Clojure's core data specification library. Unfortunately, it's been in a Limbo state since Rich Hickey's Maybe Not talk proposed a number of exciting changes which have taken time to materialize. The core team have been working on it, and there are a number of hard problems to solve, but it's been disappointing for the community. The proposed changes would have made it easier to work with the idiomatic "herd of keys" data model and brought more robustness and flexibility. In the interim, lots of codebases have converged on Malli, which is a more data-driven, batteries-included schema library. It's common to see both in use in the same codebase.

In terms of tooling, I've experienced tension between the dynamic nature of the language, its live REPL environment, and static analysis tools like clj-kondo. Before clj-kondo, you'd get editor completions and inline docs by connecting a REPL to the running system, and the tooling would inspect live variable definitions. This all still works!

But now it's common to additionally use static analysis for linting and LSP integration. The tension here comes from the fact that a lot of Clojure code relies on macros which dynamically define variables, or take bodies of code to execute in some other context. Static analysis tools like clj-kondo can't know about this without either executing the code, or being given some help. The result is that every macro definition now has to come with a corresponding function that safely builds up just enough of the expected runtime AST to be able to perform static analysis.

For me, this took away some of the joy of writing Clojure. Where before the language was self-contained, and provided all of the necessary tooling for introspection in its own runtime, now you're also required to target the separate, more limited static analysis runtime. And where before your running development system would maintain its own index of identifiers for code navigation, now that's duplicated in a separate process which can eat up significant memory. I think this is fundamentally the wrong approach to tooling for a language so strongly based on dynamism.

What's next?

On a personal level, I'm currently reflecting on what should be next in my career - taking a bit of time to process and level up before I move onto the next thing. Although I've always done pretty standard web development work, right now I'm jumping into the deep end of learning about distributed systems, CRDTs, and the user experience of local-first editing and client syncing. I'm reading lots of Martin Kleppmann's writing, re-learning some foundational algorithms, and generally feeling my head spin. It's fun to feel out of my depth again!

This kind of work makes me yearn for something more low-level. Language-wise, I still want to build for the web, which makes TypeScript a virtual certainty. But I'm also excited about Rust, because it integrates well with TypeScript via WASM, and because I like its type system and low-level affordances.

I have the growing urge to learn more about Zig. In some ways, it feels like it could be a very Clojurish language. Collapsing types down to "values programmable at compilation time" feels like the kind of innovation that Clojure would bring to parameterized static types - if it had a compilation phase. I can almost picture the first slide of an alternate-universe Rich Hickey talk defining the word "type." (Although actually I think that our universe's Rich would argue against the complexity of compilation/runtime being separate at all.)

I'm excited to bring what I've learned from Clojure to new languages and new kinds of problems. Although I'm not so narrowly focused on Clojure anymore, I'd still jump at the chance to use it again for the right project. There's just nothing like doing your favorite thing for 8 hours a day, for 8 years, to make it lose its shine.