~ / track B / clojure advanced
clojure.spec
Intermediateclojure.spec.alpha — usually called 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).
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/keysclojure.spec.alpha/s/keysSpec a map by required/optional namespaced keys.view on clojuredocs → describes 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/instrument, every call to add is checked at
runtime. With check, spec generates random inputs and verifies the :fn
invariant — 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 (informational)
clojure.spec.alpha isn't bundled in the in-browser SCI REPL, so the
snippet below is illustrative. Run it from a real Clojure REPL.
(require '[clojure.spec.alpha :as s])
(s/def ::pos-int (s/and int? pos?))
(s/def ::point (s/keys :req-un [::x ::y]))
(s/def ::x ::pos-int)
(s/def ::y ::pos-int)
[(s/valid? ::point {:x 3 :y 4})
(s/valid? ::point {:x 3 :y -1})
(s/explain-data ::point {:x 3 :y -1})]
;; => [true false {:clojure.spec.alpha/problems (...)}]To get a feel for the shape in-browser, here's a hand-rolled validator that mirrors what s/valid?clojure.spec.alpha/s/valid?True if x conforms to spec.view on clojuredocs → does under the hood:
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.