~ / track B / clojure advanced
clojure.spec
Intermediateclojure.spec.alpha (usually just "spec") is Clojure's in-the-box
contracts and generative-testing library. It's not a type system; it's a
runtime predicate system. A spec is a function that returns truthy/falsy,
plus a generator that produces sample data conforming to it. Specs decouple
the shape of a value from the code that uses it, so you can validate at
boundaries, generate test data, and explain failures.
Spec ships with Clojure (no extra dep). [[malli]] is a popular alternative with a data-driven API.
Defining specs
(require '[clojure.spec.alpha :as s])
(s/def ::email (s/and string? #(re-matches #".+@.+" %)))
(s/def ::age (s/and int? #(<= 0 % 150)))
(s/def ::role #{:admin :user :guest})
(s/def ::user
(s/keys :req-un [::email ::age]
:opt-un [::role]))
Three things to notice:
- Specs are registered under qualified keywords (
::emailresolves to:my.ns/email). Namespacing prevents collisions and lets specs travel across libraries. s/keysdescribes a map by which keys it requires/permits, not by which values. The shape is "a map with these keys; each key's value must satisfy the spec registered under that key.":req-unmeans "required, unqualified key name in the map" — so::emailregisters the spec but the map uses:email.
Validating
(s/valid? ::email "alice@example.com") ;; => true
(s/valid? ::email "nope") ;; => false
(s/conform ::user {:email "a@b.com" :age 30})
;; => {:email "a@b.com" :age 30} ;; conform returns the parsed value
(s/explain ::user {:email "nope" :age -1})
;; "nope" - failed: (re-matches #".+@.+" %) in: [:email]
;; -1 - failed: (<= 0 % 150) in: [:age]
s/explain-data gives the same info as data (a map you can program
against). s/explain-str gives a string.
Spec'ing functions
(defn add [a b] (+ a b))
(s/fdef add
:args (s/cat :a number? :b number?)
:ret number?
:fn #(= (:ret %) (+ (-> % :args :a) (-> % :args :b))))
s/fdef declares the contract for add. With clojure.spec.test.alpha
instrumented, every call to add is checked at runtime; with check, spec
generates random inputs and verifies the :fn invariant — that's
property-based testing for free (see [[test-check]] for the underlying
generator library).
conform is parsing
The deep insight: s/conform doesn't just say "valid?" — it returns a
parsed value that names the alternatives a value matched. With s/or
and s/cat, this turns spec into a parser:
(s/def ::lit
(s/or :int int? :str string? :nil nil?))
(s/conform ::lit 42) ;; => [:int 42]
(s/conform ::lit "hi") ;; => [:str "hi"]
(s/conform ::lit nil) ;; => [:nil nil]
That tagged result is what you destructure in downstream code — no need to ask "is this an int or a string?"; spec already tagged it.
Generation
Every spec has a default generator. You can ask for samples:
(require '[clojure.spec.gen.alpha :as gen])
(gen/sample (s/gen ::age) 5)
;; => (0 12 87 145 33)
You can plug in custom generators when defaults aren't realistic enough (emails, UUIDs, business identifiers).
Try it
Where to put validation
Spec at every function is excessive. The pragmatic boundary:
- System inputs — HTTP request bodies, file uploads, message-queue payloads. Validate or reject at the perimeter.
- System outputs — anything you persist or send to other services. Validate before writing, to catch regressions.
- Critical internal contracts — invariants that, if violated, cost real money. Spec a few, instrument them in dev.
- Property tests — use
s/fdefto generate inputs for the pure functions you wrote.
Don't spec literally everything. The cost is real (predicates run, errors formatted) and the maintenance cost compounds.
Spec vs Malli
| Spec | Malli | |
|---|---|---|
| Schema as | Function (predicate + generator) | Plain data (vector / map) |
| Composition | Functions and macros | Data — easy to serialize, transform |
| Generators | Built-in via clojure.spec.gen | First-class, also test.check-based |
| Errors | Human-string and explain-data | Structured, customizable |
| Status | Alpha for years; widely used regardless | Active development, fast-moving |
In greenfield projects, [[malli]] is increasingly the default because
schemas-as-data play nicely with serialization, configuration, and UI
generation. Spec remains rock-solid for instrumenting existing code and
benefiting from the parser-style conform.
Real-world
| Where | Use |
|---|---|
| HTTP request validation (Reitit) | Either spec or malli coercion in middleware |
| Datomic schema documentation | Specs alongside :db.install/_attribute schema |
clojure.spec.test.alpha/check in CI | Generative tests for pure modules |
| Component lifecycle args | Validate the system map at start |
| Library APIs | Specs as documentation of input/output shape |
| Ring middleware | Pre-validate routes that take complex JSON bodies |
| ML pipeline records | Validate feature vectors before training/inference |
Check yourself
? quiz
What does `s/conform` return for a value that matches a spec, and how is it different from `s/valid?`?
Exercise
Write specs for a CSV row representing a transaction:
date,amount,currency,memo
2026-01-15,123.45,USD,"Coffee"
::datematches#"\d{4}-\d{2}-\d{2}".::amountis a decimal number (or a string parseable as one — your call).::currencyis one of:USD :EUR :BRL.::memois optional, non-empty string.
Then write a spec for the whole row, and an fdef for a parse-row
function that takes the four strings and returns a conforming map. Use
s/explain-data to inspect a failing case.