Validate Data and Instrument Contracts with clojure.spec
Learn how to use clojure.spec to validate data shapes and instrument function contracts, with a worked example, runtime limits, and common mistakes to avoid.
03 Oct 2025, 16:01 UTC

Runtime shape errors in Clojure maps are easy to introduce and hard to trace. The useful answer is to declare reusable contracts with clojure.spec.alpha: validate data with s/valid? or s/conform, and wrap function boundaries with s/fdef plus s/instrument so bad calls fail immediately instead of surfacing as a confusing NullPointerException three layers down.
How specs work
Specs are composable predicates stored in a global registry under qualified keywords. A spec can be as simple as int? or as complex as a regex over sequences. Because the registry is global, always namespace-qualify your names—::app.config/port rather than ::port—to avoid collisions across libraries.
The library provides combinators such as s/and, s/or, s/cat, and s/keys. s/valid? returns only true or false. s/conform can return a transformed value—for example, a destructured argument map—or the keyword :clojure.spec.alpha/invalid when the data does not match.
A worked configuration
Assume Clojure 1.9 or later, where clojure.spec.alpha (spec 1) ships by default. Run the following in a project REPL or with the Clojure CLI:
;; Run in a REPL or via: clojure -M -e "(...)"
(require '[clojure.spec.alpha :as s])
(s/def ::app.config/port
(s/and int? #(<= 0 % 65535)))
(s/def ::app.config/config
(s/keys :req-un [::app.config/port]))
;; Expected check: returns true
(s/valid? ::app.config/config {:port 8080})
;; Expected check: returns false
(s/valid? ::app.config/config {:port 99999})
No special permissions are needed beyond read access to your project files. The risk here is registry pollution: if you define ::port without a namespace, another library may overwrite it.
Next, declare a function contract and instrument it:
(defn start-server
[cfg]
;; ... application logic ...
)
(s/fdef start-server
:args (s/cat :cfg ::app.config/config))
;; Expected check: returns a set containing #'start-server
(s/instrument 'start-server)
After instrumentation, calling (start-server {:port 99999}) throws an ExceptionInfo with spec explain data instead of entering the function body. This is useful in development and test environments.
What instrumentation does and does not cover
Instrumentation checks only the call boundary. It validates that arguments match the :args spec and, if declared, that the return value matches the :ret spec. It does not inspect intermediate computations inside the function. If start-server later constructs an invalid map internally, the spec will not catch it unless you explicitly call s/valid? or s/assert at that point.
Also remember that s/conform is not a general transformation engine. Use it to normalize input at a boundary—such as coercing strings to keywords—but do not embed business logic inside spec predicates. If s/conform returns :clojure.spec.alpha/invalid, treat the input as rejected.
Limits and common mistakes
- Spec is not a static type system. It checks values at runtime. A function that is never called is never checked.
s/keysis open by default.(s/valid? ::app.config/config {:port 8080 :extra true})returnstrue. For closed-map validation, add an explicit predicate that checks the exact key set.- Instrumentation adds overhead and throws. Leaving
s/instrumentactive in production can degrade performance and turn recoverable input errors into hard failures. Disable it unless you intentionally want runtime contract enforcement. - Specs do not enforce external invariants. A spec cannot guarantee that a database row exists or that a remote API is reachable. It only checks the shape of the data you hand it.
- Explain output is for humans.
s/explainands/exercise-strproduce verbose, human-readable text. Do not parse this output as a stable machine contract. - Spec 1 is not spec 2. Clojure 1.9+ includes
clojure.spec.alpha. Spec 2 variants are separate artifacts and are not drop-in replacements. Confirm which your project uses.
Verifying your setup
Before relying on specs in an existing codebase, confirm the library is present and note your version:
# Run in your project directory
clojure -M -e "(require '[clojure.spec.alpha :as s]) (println (s/valid? int? 1)) (println (clojure-version))"
Expected check: the first line prints true and the second prints your Clojure version. Then open your deps.edn (or project.clj) and confirm whether any spec 2 fork is on the classpath. If test.check is absent, generative functions like s/exercise will fail; keep specs deterministic so generated samples remain reproducible.
Choosing between s/valid?, s/conform, and s/explain
| Function | Returns | Use when |
|---|---|---|
s/valid? | true / false | You need a fast boolean gate. |
s/conform | Conformed value or :clojure.spec.alpha/invalid | You need to destructure or normalize input. |
s/explain | Human-readable EDN map | You are debugging a failure locally. |
Used carefully, clojure.spec turns implicit map shapes into explicit, reusable contracts. Validate at the edges, instrument in development, and keep the global registry clean with qualified keywords.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.