Clojure Spec: A Guided Tour
Clojure Spec (the clojure.spec.alpha library) is a system for describing the shape of data and the behavior of functions. The same spec you write to validate a value can be reused to generate example values for testing, instrument function arguments, explain failures, and express intent that stays readable.
Unlike a hand-rolled assert or a bag of keyword checks, a spec is:
- Composable — small specs combine into big ones.
- Reusable — validation, generation, conforming, and explanation all derive from one definition.
- On the edge — you typically enforce it at boundaries (API input, function entry/exit), not every 10 lines inside.
This page walks from the core primitives up to generative testing, boundaries, and advanced registry organization.
What Spec Is & Why
Given a map like a request, an HTTP response, or a database row, you usually want to say what it looks like before you trust it:
;; ad-hoc, unreadable, easy to get inconsistent
(assert (and (map? req) (string? (:method req)) (pos? (:status req))))
;; spec version — a named, reusable description
(s/def ::request (s/keys :req-un [::method ::status ::path]))The spec becomes a named contract. It documents the shape, validates it, generates examples, and explains why a value failed — all from one place.
Core Primitives
s/def + a predicate
The simplest spec is any existing clojure.core predicate:
(require '[clojure.spec.alpha :as s])
(s/def ::port int?)
(s/valid? ::port 8080) ;=> true
(s/valid? ::port "8080") ;=> falses/explain — why did it fail?
(s/explain ::port "8080")
;; "8080" - failed: int? in: [] spec: :user/ports/conform — shape-matched extraction
(s/def ::with-unit (s/cat :n number? :unit #{:px :em :rem}))
(s/conform ::with-unit [10 :px]) ;=> {:n 10, :unit :px}
(s/conform ::with-unit [10 :wat]) ;=> ::s/invalidconform returns a destructured value on success (you choose the shape with :as, :unform-able specs, or s/cat/s/keys), or ::s/invalid on failure.
Composing Specs
s/and, s/or
(s/def ::positive-number (s/and number? pos?))
(s/def ::id (s/or :uuid uuid? :slug string?))s/keys — maps
(s/def ::name string?)
(s/def ::age pos-int?)
(s/def ::person
(s/keys :req [::name] ; namespaced keys must be present
:opt [::age])) ; optionalNamespaced keywords (::name) keep specs and data namespaced-safe — the spec registry keys on them.
s/coll-of, s/every
(s/def ::names (s/coll-of string?))
(s/def ::tags (s/coll-of keyword? :min-count 1 :distinct true))s/alt — ordered alternation (like regex, for sequences)
(s/def ::optional-name (s/alt :no-name nil? :name string?))clojure.spec.alpha’s regex operators (s/cat, s/alt, s/*, s/+, s/?) work on sequences, letting you describe processing pipelines and command grammars order-sensitively.
Specifying Maps & Keys
Beyond basic s/keys, you can merge specs and extend required/optional sets:
(s/def ::id int?)
(s/def ::base (s/keys :req [::id]))
(s/def ::extended (s/merge ::base (s/keys :opt [::name])))
(s/valid? ::extended {:id 1 :name "Ada"}) ;=> true
(s/valid? ::extended {:id 1}) ;=> true (name optional)s/keys*— same but leaves other keys untouched in conforming/extraction.- Key-set specs give you clarity without rigidity: enforce what matters, ignore the rest.
Collections & Sequences
;; every element matches
(s/def ::roll (s/every pos-int?))
;; with collection modifiers
(s/def ::set-of-numbers (s/coll-of number? :into #{}))
(s/def ::sorted (s/every number? :into (sorted-set)))The :into modifier controls what collection type the conformed/generated value lands in. s/every works on any sequable; s/coll-of is collection-aware.
Function specs with s/fdef
Function specs describe :args, :ret (return), and :fn (arg/return relationship):
(s/fdef add-vecs
:args (s/cat :a (s/coll-of number?) :b (s/coll-of number?))
:ret (s/coll-of number?)
:fn (fn [{:keys [args ret]}] (= (count (:a args)) (count ret))))
(defn add-vecs [a b] (mapv + a b))stest/check can now generate arbitrary argument tuples and assert every return satisfies :ret and :fn — property-based testing from a single spec.
Boundaries & Instrumentation
Validation everywhere is slow; validation at the edge is fast and catches bugs where they cross trust boundaries.
(require '[clojure.spec.test.alpha :as stest])
;; install wrapper-functions that check each call's :args/:ret
(stest/instrument `add-vecs)
(add-vecs [1 2] [3]) ; throws: :args failed ...Custom generators with s/with-gen / s/gen
(s/def ::port (s/with-gen int?
#(s/gen #{(range 1024 65536)})))
(s/gen ::port) ;=> a random int in [1024, 65535]When a predicate has no good natural generator, s/with-gen supplies one — letting generation stay useful and realistic.
Generative Testing with stest/check
(stest/check `add-vecs)
;; => [{:spec #'add-vecs, :clojure.spec.test.alpha/count 5} ...]check runs the function spec’s :args generator over many samples, exercising :ret/:fn. Example bugs it catches that unit tests often miss:
- Argument/return arity mismatch across generated shapes.
- Non-homogeneous collections leaking through.
- Edge values (empty collections, empty maps, nil) that hand-picked test data skips.
Combine with targeted example cases (s/def + concrete values) to get the best of both.
The Spec Registry & Namespaced Keywords
Every s/def ::name registers under a fully-qualified keyword. This registry is your declarative schema catalog:
(s/get-spec ::person) ;=> (s/keys :req [::name] :opt [::age])
(s/registry) ;=> whole map of registered specs- Use
::alias(auto-namespaced to current ns) to avoid collisions. - Group related specs under a common namespace prefix (
::http/request,::http/response). - Interface boundaries: describe public contracts with specs; keep internal helpers free of premature spec overhead.
Spec vs Schema vs Malli
clojure.spec.alpha | Schema | Malli | |
|---|---|---|---|
| Ship with | Clojure (bundled) | Third-party | Third-party |
| Generators | Built-in (via s/gen) | External/awkward | Built-in |
| Instrumentation | stest/instrument | Add-on | Add-on |
| Registry keyed on | Namespaced keywords | Schemas as maps | Keywords / classes |
| Error messages | s/explain (verbose) | Customizable | Very customizable |
| Best for | Data + function contracts, generative testing, boundary validation | Simple map schemas in app code | High-perf validation, runtime error messages, transformers |
Bottom line: Spec shines when you want a single source of truth that also drives property testing and instrumentation. Reach for Malli when you need fast, customizable validation at runtime/hot paths; Schema when you want lightweight map schemas with minimal ceremony.
Pitfalls & Gotchas
- Performance — full validation on hot inner loops is costly. Validate at boundaries (
s/fdef+ instrument in dev, or an API layer), not inside every function. ::s/invalidvsnil—conformreturning::s/invalidis notnil; check withs/conformresult equality, or use the two-args/conformwith unform correctly.- Instrumentation is dev/test hygiene —
stest/instrumentwraps fns; remember tostest/unstrument(or run in a dev profile) so prod isn’t slowed. - Generators can be slow or biased — override with
s/with-genwhere default generation is skewed (e.g. large collections). - Spec is not a substitute for tests — it complements example-based tests; keep both.
- Don’t over-spec internals — spec public contracts and the data that crosses trust boundaries.
Reference Links
- Official docs: clojure.spec.alpha Reference and Guide
- Clojure Guides: Spec chapters
- Practicalli Clojure: Spec section
clojure.spec.test.alphadocs forstest/check,stest/instrument