Spec

Use clojure.spec from the REPL:

(require '[clojure.spec.alpha :as s])

Write a spec using a predicate function:

(s/valid? number? 37) ; true
(s/valid? number? :foo) ; false

Combine predicates using and logic:

(def positive-number (s/and number? #(> % 0)))
(s/valid? positive-number 9) ; true
(s/valid? positive-number 0) ; false

Combine predicates using or logic:

(def number-or-string (s/or :numeric number? :stringy string?))
(s/valid? number-or-string 13) ; true
(s/valid? number-or-string "99") ; true
(s/valid? number-or-string :gotcha) ; false

The additional keyword preceding the predicate function is required for feedback.

Create a spec for collections of certain things:

(def str-coll (s/coll-of string?))
(s/valid? str-coll ["this" "and" "that"]) ; true
(s/valid? str-coll ["this" :and "that"]) ; false

Create a spec with rules for succession:

(def chess-move (s/cat :s1 string? :n1 number? :s2 string? :n2 number?))
(s/valid? chess-move ["A" 3 "C" 5]) ; true
(s/valid? chess-move [:A 3 "C" "5"]) ; false

Specify required and optional keys for a map:

(def employee
  (s/keys :req-un [:acme.core/name
                   :acme.core/salary]
          :opt-un [:acme.core/revenue]))

The req and opt in :req-un and :opt-un stand for required and optional, respectively. The un stands for unqualified: The keys in the spec are namespace-qualified, but the ones in the validated maps need not be:

(s/valid? employee {:name "Dilbert" :salary 120000}) ; true
(s/valid? employee {:name "Topper" :salary 130000 :revenue 500000}) ; true
(s/valid? employee {:name "Dogbert" :salary 150000 :position "consultant"}) ; true
(s/valid? employee {:name "Ashok"}) ; false

Use keyword shortcuts when defining specs in their respective namespace:

(def employee
  (s/keys :req-un [::name ::salary]
          :opt-un [::revenue]))

Register and use a spec globally:

(s/def :acme.core/employee employee)
(s/valid? :acme.core/employee {:name "Alice" :salary 115000}) ; true

Same, but using shortcuts for the keywords within the same namespace:

(s/def ::employee employee)
(s/valid? ::employee {:name "Alice" :salary 115000}) ; true

Further constrain the values for the specified keys in the map:

(def employee
  (s/keys :req-un [::name ::salary]
          :opt-un [::revenue]))

(s/def ::name string?)
(s/def ::salary number?)
(s/def ::revenue number?)

(s/valid? ::employee {:name "Alice" :salary 115000}) ; true
(s/valid? ::employee {:name :alice :salary 115000}) ; false
(s/valid? ::employee {:name "Alice" :salary "too little"}) ; false

Explain the cause of a spec mismatch:

(s/explain ::employee {:name :alice :salary 115000})
;; :alice - failed: string? in: [:name] at: [:name] spec: :acme.core/name
(s/explain ::employee {:name "Alice" :salary "too little"})
;; "too little" - failed: number? in: [:salary] at: [:salary] spec: :acme.core/salary

Return value for matching spec, and invalid for mismatch:

(s/conform ::employee {:name "Alice" :salary 115000}) ; {:name "Alice", :salary 115000}
(s/conform ::employee {:name :alice :salary 115000}) ; :clojure.spec.alpha/invalid

Define a function for which to write a spec for:

(defn calc-bonus [employee percentage]
  (let [factor (/ percentage 100)]
    (+ (if (:revenue employee)
         (* factor (:revenue employee))
         0)
       (* (:salary employee) factor))))

(calc-bonus {:name "Alice" :salary 115000} 1.25) ; 1437.5
(calc-bonus {:name "Topper" :salary 130000 :revenue 500000} 1.25) ; 7875.0

Create a spec for the function’s arguments and return values:

(s/fdef calc-bonus
  :args (s/cat :employee ::employee
               :percentage number?)
  :ret number?)

Activate the function spec:

(require '[clojure.spec.test.alpha :as stest])
(stest/instrument 'acme.core/calc-bonus)

(calc-bonus {:name "Alice" :salary 115000} 1.25) ; 1437.5

(calc-bonus {:name "Alice"} 1.25)
;; Execution error - invalid arguments to acme.core/calc-bonus at (REPL:94).
;; {:name "Alice"} - failed: (contains? % :salary) at: [:employee] spec: :acme.core/employee

Exercises

For the following exercises, a Leiningen project called music and a music.core module in music/src/music/core.clj with the following content is assumed:

(ns music.core
  (:require [clojure.spec.alpha :as s]))

Songs and Musicians

Write and register two specs for maps:

  1. for a song with a name and a duration string, and
  2. for a musician with a name and an instrument.

Hint: Use the clojure.spec.alpha/keys function and the string? predicate.

Test:

(s/valid? ::song {:name "Pale Fire" :duration "4m17s"}) ; true
(s/valid? ::song {:name "Pale Fire" :duration 257}) ; false
(s/valid? ::musician {:name "Jim Matheos" :instrument "Guitar"}) ; true
(s/valid? ::musician {:name "Jim Matheos" :instrument :keytar}) ; false
Solution
(s/def ::name string?)
(s/def ::duration string?)
(s/def ::instrument string?)
(s/def ::song (s/keys :req-un [::name ::duration]))
(s/def ::musician (s/keys :req-un [::name ::instrument]))

Bands and Albums

Write and register two specs for maps:

  1. for a band with a name and a vector of musicians, and
  2. for an album with a name and a vector of songs.

Hint: Use the keys and coll-of function from the clojure.spec.alpha namespace.

Test:

(def jim {:name "Jim Matheos" :instrument "Guitar"})
(def ray {:name "Ray Alder" :instrument "Vocals"})
(def fates-warning {:name "Fates Warning" :members [jim ray]})
(def coldplay {:name "Coldplay" :members [:chris :jonny]})

(def pale-fire {:name "Pale Fire" :duration "4m17s"})
(def apparition {:name "The Apparition" :duration "5m50s"})
(def best-of {:name "Best of Fates Warning" :songs [pale-fire apparition]})
(def worst-of {:name "Best of Coldplay" :songs [:viva-la-vida :clocks]})

(s/valid? ::band fates-warning) ; true
(s/valid? ::band coldplay) ; false
(s/valid? ::album best-of) ; true
(s/valid? ::album worst-of) ; false
Solution
(s/def ::name string?)
(s/def ::duration string?)
(s/def ::instrument string?)
(s/def ::song (s/keys :req-un [::name ::duration]))
(s/def ::musician (s/keys :req-un [::name ::instrument]))

(s/def ::members (s/coll-of ::musician))
(s/def ::songs (s/coll-of ::song))
(s/def ::band (s/keys :req-un [::name ::members]))
(s/def ::album (s/keys :req-un [::name ::songs]))

Song and Album Duration

Write a spec for the parse-duration function. It shall accept a string and return an integer.

Hint: Copy the function’s code into the current project. Use the fdef function from the clojure.spec.alpha namespace and the instrument function from the clojure.spec.test.alpha namespace.

Test:

(require '[clojure.spec.test.alpha :as stest])
(stest/instrument 'music.core/parse-duration)
(parse-duration "3m15s") ; 195
(parse-duration 300)
;; Execution error - invalid arguments to music.core/parse-duration at (REPL:106).
;; 300 - failed: string? at: [:dur]
Solution
(s/fdef parse-duration
  :args (s/cat :dur string?)
  :ret number?)