Some Clojure REPL Ergonomics

July 3, 2024

In the last post I presented a technique that allows more easily interacting with test state via the REPL. To compliment this idea I'd like to share a few REPL ergonomics techniques I use to improve the experience.

uniformity in function arguments

Striving towards uniformity for function arguments can go a long ways towards quickly being able to send well-formed expressions to the REPL.

Say you have a system that encapsulates all the side-effecting operations of your program. If you're working on a function, insert-to-content-addressed-store!, that needs access to the database and the filestore, you could pass both components in as arguments: (insert-to-content-addressed-store! db-conn filestore contents). I used to prefer such explicit arguments because it helps readers know that the function will only have side-effects to the database and filestore but not other things like sending emails or making HTTP requests. Yet in terms of being able to smoothly interact with the function via the REPL, something like (insert-to-content-addressed-store! system contents) will be more approachable, especially if you consistently use the system as the first argument in other functions.

It took me some time to give up on my preference for explicit arguments but I was surprised how nice the uniformity of just one system argument was and found the trade-off of making effects explicit worthwhile.

dedicated vars for the system in each environment

Having a REPL connected to your deployed systems like staging and production can be quite incredible and liberating. That said, it comes with it's dangers, especially when you forget you're connected to a live environment and start evaluating something meant for your local dev REPL. After having this happen to us a few times, my colleagues and I came up with a helpful adaptation: instead of accessing the system through the same variable on dev, staging, and production, use distinct variables for each environment.

Thus, if you try to run (send-test-email dev/system) while your REPL is connected to production, you'll get a "missing var" error instead of sending the email.

We do it by dynamically, exposing dev/system, staging/system, production/system via interning:

inline defs

I rely on print-debugging quite a bit, which while useful, isn't so interactive. For instance, I often find myself printing a value, then wanting to check the type, and then wanting to run some follow-up code on it to check an assumption. This is were writing inline defs can help. It is a pretty simple idea and you probably already know of it. I want to be sure to mention it because embarrassingly it took me years of coding Clojure professionally to actually start using it.

Note that what I mention here is very simplistic and there are some proper tools for capturing scope for debugging, such as scope-capture.

So say you have some code roughly of the following form that gets exercised in a test and seems to be behaving poorly:

You could add a few print statements and maybe get some clarity, especially in this simplistic example. But if you want to interact with the code a bit, sprinkling in some inline defs enables you to freeze the local state encountered during a test, or REPL invocation, or even a live HTTP request:

With intermediate state defed, you can explore a bit by evaluating intermediate forms via the REPL to check the results. Doing so here reveals that compare doesn't always return -1, 1, 0, as I assumed when I wrote some code like this the other day. Strangely, for datetimes that are close to each other, you might see something like -18992, but once they are fare enough apart, you start getting just -1 and 1 for results.

If you want to reduce the amount of intervention needed by finding the proper place to add a def, you can use a reader macro like:

inline def reader macro

If you like the idea of the inline def reader macro, you can add it to your project with something like

and then in src/data_readers.clj add:

{...
 debug-tools/idef debug-tools/inline-def}

Conclusion

In this post I show changes we made to our code at Nextjournal that improved the ergonomics of interacting with the REPL. In particular, using the system as the first argument as much as possible instead of passing in individual components of the system, as well as creating dedicated vars for the test, dev, staging, and production systems to avoid evaluating code that in production that wasn't meant for it. These, combined with the use of inline defs, makes debugging with the REPL much nicer, which plays into the last post about interacting with test state via the REPL.

If you're looking for more tips around REPL ergonomics, Martin Kavalar's BabashkaConf talk is worth a watch.