~ / track B / clojure advanced

clojure.spec

Intermediate

clojure.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 (::email resolves to :my.ns/email). Namespacing prevents collisions and lets specs travel across libraries.
  • s/keys 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-un means "required, unqualified key name in the map" — so ::email registers 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

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Where to put validation

Spec at every function is excessive. The pragmatic boundary:

  1. System inputs — HTTP request bodies, file uploads, message-queue payloads. Validate or reject at the perimeter.
  2. System outputs — anything you persist or send to other services. Validate before writing, to catch regressions.
  3. Critical internal contracts — invariants that, if violated, cost real money. Spec a few, instrument them in dev.
  4. Property tests — use s/fdef to 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

SpecMalli
Schema asFunction (predicate + generator)Plain data (vector / map)
CompositionFunctions and macrosData — easy to serialize, transform
GeneratorsBuilt-in via clojure.spec.genFirst-class, also test.check-based
ErrorsHuman-string and explain-dataStructured, customizable
StatusAlpha for years; widely used regardlessActive 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

WhereUse
HTTP request validation (Reitit)Either spec or malli coercion in middleware
Datomic schema documentationSpecs alongside :db.install/_attribute schema
clojure.spec.test.alpha/check in CIGenerative tests for pure modules
Component lifecycle argsValidate the system map at start
Library APIsSpecs as documentation of input/output shape
Ring middlewarePre-validate routes that take complex JSON bodies
ML pipeline recordsValidate 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"
  1. ::date matches #"\d{4}-\d{2}-\d{2}".
  2. ::amount is a decimal number (or a string parseable as one — your call).
  3. ::currency is one of :USD :EUR :BRL.
  4. ::memo is 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.

 status: new