← Back to DevBytes

Clojure Type System: Static vs Dynamic Typing

Understanding Clojure's Type System: Static vs Dynamic Typing

Clojure is a dynamically typed language that runs on the JVM, but its type system is far from simple. While the language itself is dynamically typed, the Clojure ecosystem offers powerful tools for optional static type checking, data validation, and specification. This tutorial explores what dynamic typing means in Clojure, why it matters, how you can introduce static-like checks using clojure.spec and core.typed, and best practices for leveraging both approaches.

What Is Dynamic Typing in Clojure?

In a dynamically typed language like Clojure, types are associated with values at runtime, not with variables or function parameters. You can pass any value to a function without declaring its type beforehand. The language checks type correctness only when the code is executed.

(defn add [a b]
  (+ a b))

;; Works with numbers
(add 3 5)          ;; => 8

;; Also works with strings? No, but it will throw a runtime error
(add "hello" " world")  ;; ClassCastException: java.lang.String cannot be cast to java.lang.Number

The function add does not declare that its arguments must be numbers. It only fails when a non-numeric argument is actually used. This flexibility speeds up prototyping but can lead to runtime surprises in larger codebases.

Why Does It Matter?

The choice between dynamic and static typing affects development speed, safety, and tooling. Dynamic typing shines in exploratory programming and REPL-driven development, where you can quickly test ideas without type annotations. However, as projects grow, the lack of compile-time type checks can introduce subtle bugs that are only caught at runtime. Clojure addresses this by providing optional static checking and data specification tools.

How to Use Static Typing Options in Clojure

1. clojure.spec – Data Specification and Validation

clojure.spec (introduced in Clojure 1.9) is not a type system in the traditional sense, but it provides a way to describe the shape of data (including functions) and validate it. It can be used both for runtime checking and for generative testing.

(require '[clojure.spec.alpha :as s])

;; Define a spec for a number
(s/def ::age pos-int?)

;; Validate data
(s/valid? ::age 30)   ;; => true
(s/valid? ::age -5)   ;; => false

;; Spec for a function: takes two numbers, returns a number
(s/fdef add
  :args (s/cat :a number? :b number?)
  :ret number?)

(defn add [a b] (+ a b))

;; Instrument the function for runtime checking
(s/instrument `add)

;; This will pass
(add 2 3)   ;; => 5

;; This will throw an exception with a detailed error
(add "x" 1) ;; Exception: :args did not match spec

Using s/fdef and s/instrument, you can enforce type-like contracts on your functions during development and testing. In production, you can remove instrumentation to regain full dynamic speed.

2. core.typed – Optional Static Type Checking

core.typed is a library that brings gradual typing to Clojure. You annotate function arguments and return types, and the checker verifies type correctness at compile time. It integrates with the existing dynamic codebase.

(require '[clojure.core.typed :as t])

;; Annotate a function
(t/ann add [Number Number -> Number])
(defn add [a b] (+ a b))

;; Type checks pass
(add 1 2) ;; => 3

;; Type error caught at check time
(t/cf (add "x" 1)) ;; Type mismatch: expected Number, got String

core.typed can be used incrementally – you can annotate only the hot paths or public APIs while leaving internal code dynamic. Note that it adds a build step and may not be as widely adopted as spec.

3. Schema – Another Data Validation Library

schema (by Prismatic) is similar to spec but predates it. It provides a concise way to define data shapes and validate them.

(require '[schema.core :as s])

;; Define a schema
(s/defschema Person {:name s/Str :age s/Int})

;; Validate
(s/validate Person {:name "Alice" :age 30})  ;; => {:name "Alice", :age 30}
(s/validate Person {:name "Bob" :age "old"}) ;; throws schema.core.ValidationError

Schema also supports function schemas and can be used for runtime checking. However, spec is now the official Clojure tool and integrates better with generative testing.

Best Practices for Static vs Dynamic Typing in Clojure

Conclusion

Clojure's type system is fundamentally dynamic, offering unmatched agility during interactive development. However, the language provides powerful optional tools like clojure.spec and core.typed that bring the benefits of static typing—early error detection, documentation, and contract enforcement—without sacrificing the dynamic nature of the language. By combining dynamic typing for rapid iteration with spec-based validation for critical boundaries, you can build robust, maintainable systems that are both flexible and safe. The key is to apply the right level of type discipline to the right part of your code, leveraging Clojure's hybrid nature to your advantage.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles