Skip to content
AgileLogics
7 min read

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/def registers a spec under a qualified keyword (::name is :user/name here). Any predicate function is a spec.
  • s/and, s/or, s/int-in, s/coll-of, and s/keys combine specs.
  • s/keys checks 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.