The Wrong Question About Type Systems
After more than eight years of professional Clojure development, I still get asked the same question: "Don't you miss types?"
I recently swapped Clojure for Typescript and Go, due to a career move. But looking back at my Clojure years, I realize the question assumes a tradeoff I never experienced. It frames the choice as safety versus convenience, as if I was trading correctness for speed. The longer I think about it, the more I believe the question comes from false assumptions.
Let me explain.
Taking the arguments seriously
I have heard every argument for static types. Rather than dismissing them, I want to engage with the strongest versions.
"Types enforce contracts across large teams. When fifty engineers touch a codebase, the argument goes, types become documentation that the compiler enforces. No miscommunication, no runtime surprises at integration boundaries."
But in dynamic languages, you can also solve for documentation and correctness by adding schema validation at the boundaries. Tools like Malli or Spec can give you strong, checkable guarantees at the seams where parts meet. If your architecture consists of small, focused components with clear interfaces (like the Polylith architecture), the boundaries are most of your surface area. The "interior" where unvalidated data flows is minimized.
"Types enable refactoring with confidence. Change a function signature, and the compiler shows you everything that breaks. In a dynamic language, you rely on tests and grep."
Modern Clojure tooling handles this well. Cursive, Calva, and clj-kondo all provide structural navigation and refactoring. More importantly, Clojure's culture encourages additive change over breaking change. Rich Hickey's Spec-ulation keynote makes the case that breaking changes are simply broken. You don't rename things and hope. You deprecate, you evolve, you maintain compatibility.
"Types encode complex invariants. When you want Money<USD> and Money<EUR> to be incompatible, types make invalid states unrepresentable."
Specs and Malli schemas can encode and validate invariants like this, with instrumentation that checks at runtime. That isn't the same as making misuse impossible by construction, but it can still be very effective in practice. The difference is compile-time versus runtime checking. But here is the thing: many mainstream typed languages are not as safe as advertised. TypeScript has any and type assertions. Go has interface{} and nil panics. Java has null everywhere and erased generics. They still prevent plenty of real bugs, but the guarantee surface is often smaller than people assume. The truly rigorous type systems like Haskell or Idris exist, but they're rarely what people mean when they argue for types.
"Types improve editor tooling and discoverability. Autocomplete, jump-to-definition, 'what can I call on this?' Types make all of this instant."
In Clojure, the answer is simpler: if it is a function, it is callable. The data is maps and vectors. You already know what operations exist. What you still need is a shared, checkable description of shapes and invariants, and that is where schemas and specs earn their keep. And the REPL gives you something better than autocomplete. You can try it and see what happens in milliseconds.
What is actually different
I will concede some ground. There are things types give you that Clojure handles differently.
Exhaustiveness checking is real. When you have a closed set of variants, the compiler can tell you when you forgot a case. In Clojure, a cond might silently return nil. You catch this with tests, but you have to write them.
Propagation is real. Types flow through your entire codebase automatically. With schemas, you annotate boundaries explicitly. Types are more pervasive. But pervasive can also mean noisy.
Compile-time guarantees are real. "Impossible to compile" is different from "fails when instrumented." For a two-hour batch job, finding out at minute ninety hurts.
But that last point deserves scrutiny. When you develop at the REPL, testing every small piece as you write it, connected to a running system, how often does something survive to minute ninety that types would have caught? The batch job failure is a symptom of a development process where feedback comes too late. Clojure's REPL-driven workflow means you have already exercised the code paths before you commit.
Types as social technology
After taking apart the technical arguments, I arrived at what I think is the honest answer: types are also a social technology, not just a technical one.
They lower the bar for hiring. A larger talent pool can be productive faster. They help with onboarding. The codebase is "self-documenting." They simplify code review. Less context is needed to understand changes.
But even these claims dissolve under examination.
On self-documentation: I have seen typed codebases with data: any, payload: Map<String, Object>, and handleThing(x: SomeAbstractFactoryBean<T extends BaseEntity>). This documents nothing. Bad types might be worse than no types. They give false confidence. Good types require discipline. But discipline is exactly what makes specs and tests useful. We are back to: types do not replace discipline, they move where it is applied.
On easier code review: In practice, dependency injection-heavy typed OOP codebases have a different problem. You click on a method and land on an interface. You click on the interface and find four implementations. Which one is actually called? You check the dependency injection configuration, maybe in a separate XML file or scattered across annotations. You finally find the code, and it is two hundred lines because mutability requires defensive ceremony.
In Clojure: it is a function. Click on it and you land on the code, not an interface. It is fifteen lines because data goes in and data comes out. And if you are still confused, you can run it in the REPL right now.
The indirection tax in typed OOP is massive and rarely acknowledged. (This is a critique of certain enterprise OOP/DI styles, not of typed FP languages like Haskell or OCaml.) "Less context needed" assumes the architecture is simple. But the architecture is rarely simple because the type system encourages abstraction layers.
On the deeper issue: Working on the Polylith architecture taught me something that reframes this entire debate. Most software complexity traces back to one thing: coordination. When you change something in one place and have to update something else to match, that is coordination. When two parts of a system need to agree on a shape or a contract, that is coordination. When teams need to synchronize their understanding, that is coordination.
Joakim Tengstrand, who created Polylith, wrote about this extensively in The Origin of Complexity. His insight is that almost every design principle that works, from "don't repeat yourself" to "use pure functions" to "make small commits," can be reframed as reducing coordination.
Types shift coordination. They can reduce coordination by making expectations explicit and mechanically checked. But when type information flows everywhere, every function signature becomes a contract that other parts must coordinate with. Change a type, and the ripple spreads. The more elaborate (and the more globally shared) the type model, the more coordination it can demand. Is an email address really "owned" by your User class? Is a phone number part of your Contact type? These things exist in the world independent of your object hierarchy. Types can push you into ownership decisions that may not reflect reality, and those decisions ripple through your codebase as coupling.
The biggest bugs I have seen were never type errors. They were misunderstandings about the domain. They were wrong assumptions about what the system should do. They were coordination failures between teams or between code and reality. Type systems do not catch these. You need testing anyway. And the coupling that types introduce makes the system harder to change when you discover you were wrong.
The reframe
Here is what I have come to believe: the question is not types versus no types. That framing misses everything important.
Clojure works not because it lacks types, but because it is a system of design decisions that reinforce each other. The dynamism is one piece. Remove any of the following pieces, and the whole would be weaker.
REPL-driven development. Not just "a REPL." Most languages have that. Clojure's REPL is connected to your running program. You are not testing in isolation. You are reaching into a live system and poking it. You do not imagine what will happen, you try it. The feedback loop is seconds. This only works because of dynamism. Redefining a function without restarting is trivial when there is no type to invalidate.
Functional programming with immutability by default. These eliminate entire categories of bugs that types exist to prevent. "What state is this object in?" does not apply when there is no mutable state. "Who changed this?" Nobody. Mutable state introduces coordination. Every piece of code that can mutate shared state needs to coordinate with every other piece. Immutability removes that entire category of coordination from your system. In many ecosystems, types are used as guardrails around mutation and aliasing. Without mutation, you need fewer guardrails. Composition becomes trivial.
Homoiconicity. Code is data. Macros actually work. Your tooling, your editor, your REPL, your tests can all manipulate code the same way they manipulate data. This is why Clojure tooling caught up despite a smaller community. You are not parsing a complex grammar. You are reading lists.
Data-oriented programming. You use maps, vectors, sets. Not custom classes. Every function you already know works on every piece of data. No "how do I get the name out of this Person object." It is just (:name person). As Alan Perlis wrote in his famous Epigrams on Programming: "It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures."
Persistent data structures. Immutability would be impractical if every change required copying everything. Structural sharing makes it fast. This implementation detail is what makes the functional style viable for real work.
The sequence abstraction. Vectors, lists, maps, sets, files, database results, lazy infinite streams. They all respond to map, filter, reduce. You learn these operations once. They work everywhere.
Explicit state when needed. Atoms, refs, agents. Each with clear semantics. State is not forbidden. It is controlled. The language acknowledges reality while making the boundaries visible.
Spec and Malli as libraries, not language features. Types are usually baked in. Love them or not, you live with them. Clojure made validation optional and data-driven. You describe your data with data. You can generate tests, documentation, and validation from the same spec. Homoiconicity paying dividends again. (Yes, there is Typed Clojure / core.typed. This post is about idiomatic Clojure practice in the dynamic tradition.)
JVM stability and ecosystem access. You get to be radical in language design while being conservative in deployment. No new runtime to trust. Production-proven garbage collection, profiling, monitoring. Pragmatism that lets the interesting parts flourish.
Core team design philosophy. Rich Hickey's Simple Made Easy talk articulates this best: simple is not the same as easy. Simplicity is about lack of interleaving, about things having one role. As Dijkstra said, "Simplicity is a prerequisite for reliability." The Clojure core team embodies this. The refusal to add features just because people ask. No broken backwards compatibility. Taste and restraint. Rare in language design.
What do you optimize for?
Types optimize for one thing: checking certain properties before running. Clojure optimizes for something else: understanding and changing code quickly.
In other words: static types trade local friction for global guarantees; Clojure trades some global guarantees for interactive exploration, simpler composition, and lower incidental complexity.
These are different goals. Neither is wrong. But you cannot fully have both.
If you have a disciplined team that writes schemas and tests and keeps feedback loops short, I cannot name many domains where static types are a decisive technical advantage. There might be domains where types are convenient: compilers, language tooling, heavily generic library code. But "convenient" is weaker than the usual claims.
The tradeoff is not safety versus danger. It is discipline versus enforcement. External enforcement has costs: ceremony, indirection, slower feedback loops, and coordination overhead that compounds over time. Discipline has costs too: you have to actually maintain it.
After eight years, I know which trade I prefer. Solve problems, not puzzles, as Rich Hickey says. The question was never really about types.
Further reading
- Simple Made Easy by Rich Hickey (2011)
- Spec-ulation Keynote by Rich Hickey (2016)
- The Origin of Complexity by Joakim Tengstrand (2019)
- Polylith Architecture by Joakim Tengstrand
- Epigrams on Programming by Alan Perlis (1982)
- "Static vs. Dynamic" Is the Wrong Question by Craig Stuntz (2016)