clojure.spec: Notes from Clojure/conj 2016
Table of Contents
1. Background
All five talks are in the ClojureTV Clojure/conj 2016 playlist; titles and speakers resolved via YouTube oEmbed.
clojure.spec received significant attention at Clojure/conj 2016 including:
- Halloway's example for ETL (Simplifying ETL with Clojure and Datomic),
- Rohner's Spectrum (Spectrum, a library for statically "typing" clojure.spec),
- Composing music with clojure.spec (Wojciech Franke),
- Normand's drawing examples (Building composable abstractions), and
- underpinnings in Hickey's keynote (Spec-ulation).
2. Setup
Background on the rationale of clojure.spec is noted at https://clojure.org/about/spec. As noted it provides composability of entities with definitions similar to
3. Examples
Earlier version claimed (gen/generate (s/gen ::person)) returns a map; it throws "Couldn't satisfy such-that predicate after 100 tries" because the ::email regex filters random strings. Added s/with-gen; all blocks below run on Clojure 1.12.6, spec.alpha 0.6.249, test.check 1.1.3.
The blocks below run in order in one namespace. Generators need
test.check on the classpath; without it s/gen throws
FileNotFoundException for clojure/test/check/generators.
clojure -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.12.6"}
org.clojure/test.check {:mvn/version "1.1.3"}}}'
3.1. Basic specs
(require '[clojure.spec.alpha :as s]
'[clojure.spec.gen.alpha :as gen])
(s/def ::name (s/and string? #(> (count %) 0)))
(s/def ::age (s/and int? pos?))
;; A regex predicate is a filter, not a generator: random strings almost
;; never match, so the generator has to be supplied.
(s/def ::email
(s/with-gen (s/and string? #(re-matches #".+@.+\..+" %))
#(gen/fmap (fn [[user host]] (str user "@" host ".com"))
(gen/tuple (gen/not-empty (gen/string-alphanumeric))
(gen/not-empty (gen/string-alphanumeric))))))
(s/def ::person (s/keys :req [::name ::email] :opt [::age]))
(s/valid? ::person {::name "Alice" ::email "alice@example.com"})
;; => true
(s/explain ::person {::name "" ::email "invalid"})
;; "" - failed: (> (count %) 0) in: [:user/name] at: [:user/name] spec: :user/name
;; "invalid" - failed: (re-matches #".+@.+\..+" %) in: [:user/email] at: [:user/email] spec: :user/email
;; conform parses: regex specs destructure a sequence into a labelled map
(s/conform (s/cat :name ::name :age (s/? ::age)) ["Alice" 42])
;; => {:name "Alice", :age 42}
3.2. Generative testing
Sample output is random; what is stable is that every generated value conforms.
(gen/sample (s/gen ::name) 5)
;; => e.g. ("X8" "h" "J4" "9" "HGhO")
(every? #(s/valid? ::person %) (gen/sample (s/gen ::person) 100))
;; => true
3.3. Function specs
stest/check generates arguments from :args, calls the function, and
checks :ret (and :fn, if given). stest/instrument checks only
:args, at call time.
(require '[clojure.spec.test.alpha :as stest])
(defn greet [person]
(str "Hello, " (::name person) "!"))
(s/fdef greet
:args (s/cat :person ::person)
:ret string?)
(-> (stest/check `greet) stest/summarize-results)
;; {:sym user/greet}
;; => {:total 1, :check-passed 1}
(stest/instrument `greet)
;; => [user/greet]
(greet {::name "x"})
;; throws: Call to user/greet did not conform to spec.
;; (missing ::email; failing pred is (contains? % :user/email))
5. Spec lifecycle diagram
The diagram below traces a single registered spec through the four operations that make up the day-to-day API surface: validation, conformance/parsing, explanation, and generator-driven testing.
// clojure.spec lifecycle -- registry plus four operations on one value
digraph clojure_spec_flow {
rankdir=LR;
graph [bgcolor="white", fontname="Helvetica", fontsize=11,
pad="0.3", nodesep="0.3", ranksep="0.35"];
node [shape=box, style="rounded,filled", fontname="Helvetica",
fontsize=10, fillcolor="#dbeafe", color="#888"];
edge [color="#aaa"];
// Registration phase
subgraph cluster_register {
label="Registration";
color="#1d4ed8";
fontname="Helvetica"; fontsize=10;
sdef [label="(s/def ::person ...)",
fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
registry [label="spec registry\n(::person → spec)", shape=cylinder,
fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
sdef -> registry;
}
// Input value
input [label="input value\n{::name \"Alice\" ::email \"a@b.c\"}",
fillcolor="#fef9c3", color="#a16207", fontcolor="#a16207"];
// Four downstream operations
subgraph cluster_ops {
label="Operations";
color="#15803d";
fontname="Helvetica"; fontsize=10;
valid [label="(s/valid? ::person v)",
fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"];
conform [label="(s/conform ::person v)",
fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"];
explain [label="(s/explain ::person v)",
fillcolor="#fee2e2", color="#b91c1c", fontcolor="#b91c1c"];
gen [label="(s/gen ::person)",
fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"];
}
// Outputs
bool [label="true | false", shape=note,
fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"];
parsed [label="parsed value\nor :clojure.spec.alpha/invalid", shape=note,
fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"];
report [label="human-readable\nproblem report", shape=note,
fillcolor="#fee2e2", color="#b91c1c", fontcolor="#b91c1c"];
generator [label="test.check generator", shape=note,
fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"];
samples [label="generative tests\n(stest/check, gen/sample)",
fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"];
// Wiring: registry resolves the keyword for every op
registry -> valid [style=dashed];
registry -> conform [style=dashed];
registry -> explain [style=dashed];
registry -> gen [style=dashed];
// Wiring: input feeds the first three ops
input -> valid;
input -> conform;
input -> explain;
// Outputs
valid -> bool;
conform -> parsed;
explain -> report;
gen -> generator -> samples;
}
6. Since then
6.1. spec never left alpha
Maven Central: spec.alpha latest 0.6.249 (2026-01-02, CHANGES.md: "Update parent pom and Clojure dep to 1.12.4"); last change under src/main/clojure is 2023-03-08. No org.clojure/alpha.spec artifact on Central (404); spec-alpha2 README still says "work in progress ... may change without warning" and documents git-dep use only.
Spec shipped with Clojure 1.9 as the separate org.clojure/spec.alpha
library and is still there: Clojure 1.12.6 pulls in spec.alpha 0.5.238,
and the newest release, 0.6.249 (January 2026), is a dependency bump.
The last change to the library source was in March 2023. Read against
Hickey's Spec-ulation keynote – accrete compatibly, or take a new name
– the outcome is consistent: the redesign went into a new namespace
instead of breaking this one.
That redesign is spec-alpha2
(clojure.alpha.spec). It separates a map's shape (s/schema) from
which keys a given context requires (s/select), answering the most
common complaint about s/keys. It has never been published to Maven
Central; the README still describes it as a work in progress, usable as
a git dependency.
6.2. What filled the gap
Malli (0.20.x as of this writing)
kept the predicate-as-schema idea and made schemas plain data: vectors
that can be serialized, transformed, merged, and exported to JSON Schema
(malli.json-schema). That last property matters because the
Swagger definitions this page compared spec to became
OpenAPI 3.1, whose schema
object is JSON Schema. A data schema can be translated to that target;
a spec registry of JVM functions has to be reverse-engineered.
JSON Schema for Polyglot Engineers shows the
same ::person shape in both.
Spectrum, Rohner's static checker over specs, stopped receiving commits in April 2021. Static checking for Clojure did not come from specs.
6.3. What held up: generative testing
grep over this repo at a4666173: 69 files under test/ require clojure.test.check; 0 files in src/ or test/ require clojure.spec.alpha; 2 namespaces require malli (src/wal_sh/tools/transform/spec.cljc, src/wal_sh/site/domains.clj).
The durable part of spec turned out to be the part it borrowed.
s/gen is a thin layer over test.check,
and test.check is what this site's own code uses: 69 test files require
it, none require clojure.spec.alpha, and malli appears in two
namespaces. The one place a schema and a property sit together is the
transform codec, where a malli refinement schema, a test.check
round-trip property, and a Lean proof state the same invertibility fact
three ways (see Reversible Pipeline Transforms).
Two lessons from running those suites carry over directly to spec's
generators. The ::email fix above is one: a filtering predicate is not
a generator, and such-that gives up after 100 tries. The other is that
a property failing on one random seed is a found bug, not a flake –
Property-Based Test Shrink Discipline
covers capturing the shrunk counterexample before it disappears.
spec still turns up where it is the shortest notation for an invariant:
the chat-mutation contract
states transcript well-formedness as s/def predicates, and the
Dafny spec transpiler proposal
derives clojure.spec.alpha (or malli) checks from Dafny requires /
ensures clauses for the boundary where verified code meets untrusted
input.