clojure.spec
Prerequisites: Maps, Destructuring
clojure.spec describes the shape of data — what a valid user,
order, or function argument looks like — and uses that description to
validate, explain errors, parse, and generate test data. It ships with
Clojure as clojure.spec.alpha.
Specs and validation
(require '[clojure.spec.alpha :as s])
(s/def ::name (s/and string? #(not (clojure.string/blank? %))))
(s/def ::age (s/int-in 0 150))
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::user (s/keys :req [::name ::age] :opt [::email]))
(println (s/valid? ::age 36))
(println (s/valid? ::age -1))
(println (s/valid? ::user {::name "Ada" ::age 36}))
(println (s/valid? ::user {::name "Ada" ::age 36 ::email "not-an-email"}))
(println (s/valid? ::user {::name "Ada"}))
true
false
true
false
false
s/defregisters a spec under a qualified keyword (::nameis:user/namehere). Any predicate function is a spec.s/and,s/or,s/int-in,s/coll-of, ands/keyscombine specs.s/keyschecks that required keys are present and that every key with a registered spec has a valid value — even optional ones.
Explaining errors
(require '[clojure.spec.alpha :as s])
(s/def ::age (s/int-in 0 150))
(s/def ::tags (s/coll-of keyword? :kind vector?))
(s/def ::user (s/keys :req-un [::age ::tags]))
(print (s/explain-str ::user {:age 200 :tags [:admin "oops"]}))
(println (s/explain-str ::user {:age 30 :tags [:ok]}))
"oops" - failed: keyword? in: [:tags 1] at: [:tags] spec: :user/tags
200 - failed: (int-in-range? 0 150 %) in: [:age] at: [:age] spec: :user/age
Success!
explain-str (and explain-data, which returns the same information
as data) says exactly which value failed which predicate at which path.
:req-un means "unqualified keys" — :age in the data, checked with
the ::age spec.
Parsing with conform
(require '[clojure.spec.alpha :as s])
(s/def ::id (s/or :number int? :text string?))
(println (s/conform ::id 42))
(println (s/conform ::id "abc-1"))
(println (s/conform ::id :bad))
(s/def ::command (s/cat :verb #{:move :turn} :amount (s/? int?) :unit (s/? keyword?)))
(println (s/conform ::command [:move 10 :cm]))
(println (s/conform ::command [:turn]))
[:number 42]
[:text abc-1]
:clojure.spec.alpha/invalid
{:verb :move, :amount 10, :unit :cm}
{:verb :turn}
conform returns the data tagged with how it matched (or
:clojure.spec.alpha/invalid). Regex-like specs (s/cat, s/?, s/*,
s/+) describe sequences — this is how Clojure itself validates the
syntax of defn and let and produces helpful macro errors.
Specifying functions
(require '[clojure.spec.alpha :as s]
'[clojure.spec.test.alpha :as stest])
(defn discount [price pct]
(* price (- 1 (/ pct 100))))
(s/fdef discount
:args (s/cat :price (s/and number? pos?) :pct (s/int-in 0 101))
:ret number?)
(stest/instrument `discount)
(println (discount 200 25))
(println (try (discount 200 150)
(catch Exception e (first (clojure.string/split-lines (ex-message e))))))
150N
Call to user/discount did not conform to spec.
(150N: the ratio arithmetic produced an exact BigInt.) s/fdef
specifies a function's arguments and return value. With
instrument turned on (typically in development and tests), calls with
invalid arguments fail immediately with a spec explanation.
Generative testing
With the test.check library on the classpath, specs can generate
random valid data ((s/gen ::user)), and (stest/check discount)calls a function with hundreds of generated inputs, checking:retand:fn`
specs — property-based testing for free.
Malli is a popular data-driven alternative to spec, describing schemas as plain vectors and maps; both are widely used.