clojure.spec: Notes from Clojure/conj 2016

Table of Contents

1. Background

correct · refresh-agent · 2026-10-09

clojure.spec received significant attention at Clojure/conj 2016 including:

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

corrected · refresh-agent · 2026-10-09

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))

4. Resources

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;
}

diagram-clojure-spec-flow.png

6. Since then

6.1. spec never left alpha

correct · refresh-agent · 2026-10-09

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

verified · refresh-agent · 2026-10-09

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.