Thriving in the dynamically type-checked hell scape of Clojure
People often come to me asking "I love the idea of Clojure, but how do you write code without types?". I struggle to answer this question. I have no idea what they're talking about half the time. The nuance of strong and weak typing, and static vs dynamic type checking, is often lost on people. I think they want their tools to tell them what to do. I don't struggle with that like others. I never have. It never occured to me that I might just be a weirdo.
Weird life
Like a lot of things, I think I can directly attribute my cognitive model of writing code to how I learned to program. Right around the time I was getting into Linux in high-school and becoming a script kiddy. I learned how to program in C. At first on Windows with something called Bloodshed Dev C++, then I quickly graduated to the terminal because of this cool thing called Linux. I used GCC and GEdit for a long time. Not because I'm some arogant asshole (debateable). I didn't really know better. Then my hacky brain stole this book called Hacking: The Art of Exploitation. At sixteen I learned how to disassemble the C programs I had written and attach GNU debugger (GDB) to examine memory, graduating from:
printf("HERE DUMBASS: %s", *buf);
This "bad" habit of GEdit + command line followed me to University where I coasted and watched people new to programming struggle writing Java. I used it during labs, competitions, assignments, etc. The first time I was actually introduced to Eclipse was when my friend used it on our Intro to Software Engineering project.
All of this was made worse when I finally dropped out of my Computer Science program to work in the industry. My first real developer job was writing Clojure, and the office preference was to use Emacs prelude + CIDER.
Every office has it problems, but what I quickly learned was that documentation was always out of date. I had to build the muscle memory to just read the code which wasn't out of date, ever. I suspect this is why I always find myself reading the Clojure source code for interesting technical nuggets. Even long after I was a Go developer, I still read code everywhere.
Okay, so what?
The less code kept in the developer's head the better. The typical model of software development follows a loop of getting product requirements, understanding enough of the codebase to formulate a solution, and then implementing that solution. More code in the head leads to more complexity.
Blackboxing code addresses complexity by reducing something strictly to inputs and outputs. I think people coming from statically type-checked languages conflate complier errors and type declarations with blackboxing. In reality, they've traded flexibility for guardrails. While I argee that dynamic type checking requires a little bit more cognitive load, docstrings and idioms cut down on cognitive load, and they are far more descriptive than type declarations on function arguments.
Similarly, by sending type mismatching off to the compile time, rather than runtime, those developers lose out on the flexibility on deciding what to do with that type mismatch. Of course, the type mismatch fault can mitigated by testing and assertions. Often, it doesn't matter in Clojure anyways.
While Clojure is a dynamically type-checked language, it is also strongly-typed. Clojure types are several types at once rather than implicitly converting types between function calls. Collections (vectors, maps, sets, and lists) adhere to enough type interfaces they work with many functions like filter
, reduce
, partition
, group-by
, etc. get
, assoc
, and update
not only operate on maps, but can also operate on vectors and so on.
However, while developers might have a hard time shooting themselves in the foot, that doesn't mean they can't shoot their colleagues in the foot. Here's some ways to avoid that:
Idioms
Clojure idioms convey information about the type of value being passed a function, macro, and methods at a glance like type declarations. They are great for smaller, general functions.
Idioms only capture part of the story, and are not entirely suitable for all cases. Sometimes, we want our bindings or argument names to convey more domain information. We can describe them in docstrings.
Docstrings
Docstrings are first-class documentation for functions. Clojure tools like editors and documentation generators look at docstrings first, so It's important to do well. Docstrings should describe what the function does, takes as parameters, and returns. Use backticks functions parameters and other important bits like so:
Comments
Similar to docstrings are Clojure's comments. These are comments like any other comments from any language, but Clojure comes with a couple more types of comments:
- semi-colon comments
;
- typical boring comments, completely ignored - reader comments
#_
- tells the reader to ignore the next form - rich comments
(comment ....)
- form evaluates contents, but unreachable
REPL
Some IDEs allow storing and evaluating REPL-like expressions in Clojure. If you haven't heard of a REPL, it's a Read-Eval-Print-Loop. Clojure features a robust REPL compared to other programming languages as developers can spin up their application process and access the state of the application while it's running.
It's great for debugging and hardening code, and the closest to thing to static type checked process of compiling and waiting for type errors. Except the Clojure REPL doesn't have to build the entire program again, so it can be much faster to iterate with than a statically type-checeked compiler.
Here's a dev script I use to bootstrap my process with the main entry point :main-opts ["dev.clj"]
in my deps.edn
alias. I use (CIDER) nREPL to connect to the process once it's running.
Once connected, I can deref
the server atom or alter a root var to enable debug logs or prototype functions for my current dev task. nREPL can be very handy to debug production systems as it can be used with a SSH tunnel for secure access.
Clojure.spec
My thoughts on spec are well known at this point. I really don't like bolting on quasi-static-but-at-run-time-type-checking. I find it's best used for the system boundaries to check and coerce input as well as some critical code pathways. Developers should exercise caution when using spec though. Codebases with too much spec suffer from rigidity, making changes harder.
Rigidity is brought on by the false promise of spec test generators (using spec to generate function test cases). If you want to use generators, you can't just sprinkle in some Clojure spec. Spec generators are great at generating obscure test cases. They create a cycle of tweaking functions and specs, so it doesn't generate unintended non-sense and the function can handle the desired input. I find the time spent on this has little value for the payoff and makes codebases more difficult to develop in.