TLDR: this post is a copy of readme file from the GitHub repo.

Maps are quite common in Clojure, and thus s/keys specs too. Here is a common example:

(s/def :profile/url    string?)
(s/def :profile/rating int?)
(s/def ::profile
  (s/keys :req-un [:profile/url
                   :profile/rating]))

(s/def :user/name    string?)
(s/def :user/age     int?)
(s/def :user/profile ::profile)
(s/def ::user
  (s/keys :req-un [:user/name
                   :user/age
                   :user/profile]))

What’s wrong with it? Namely:

  • each key requires its own spec, which is verbose;
  • keys without a namespace still need it to declare a spec;
  • for the top level map you use the current namespace, but for children you have to specify it manually, which leads to spec overriding (not only you have declared :user/name);
  • keys are only keywords which is fine in 99%, but still;
  • there is no a strict version of s/keys which fails when extra keys were passed. Doing it manually looks messy.

Now imagine if it would have been like this:

(s/def ::user
  {:name string?
   :age int?
   :profile {:url? string?
             :rating int?}})

or this (full keys):

(s/def ::user
  #:user{:name string?
         :age int?
         :profile #:profile{:url? string?
                            :rating int?}})

This library is it to fix everything said above. Add it:

;; deps
[spec-dict "0.1.0"]

(require '[spec-dict :refer [dict dict*]])

A simple dictionary spec:

(s/def ::user-simple
  (dict {:name string? :age int?}))

(s/valid? ::user-simple {:name "Ivan" :age 34})

By default, extra keys are OK:

(s/valid? ::user-simple {:name "Ivan" :age 34 :extra 1})

Keys of different types:

(s/def ::user-types
  (dict {"name" string?
         :age int?
         'active boolean?}))

(s/valid? ::user-types {"name" "Ivan" :age 34 'active true})

The dicts can be nested:

(s/def ::post-nested
  (dict {:title string?
         :author (dict {:name string?
                        :email string?})}))

(s/valid? ::post-nested
          {:title "Hello"
           :author {:name "Ivan"
                    :email "test@test.com"}})

A dict may reference another dict:

(s/def ::post-author
  (dict {:name string?
         :email string?}))


(s/def ::post-ref
  (dict {:title string?
         :author ::post-author}))

or be a part of a collection as well:

(s/def ::post-coll-of
  (dict {:title string?
         :authors (s/coll-of ::post-author)}))

The inner map can be prefixed to get full keys:

;; spec
(dict #:user{:extra/test boolean?
             :name string?
             :age int?})

;; data
{:extra/test false
 :user/name "Ivan"
 :user/age 34}

The dict consumes multiple maps on creation, the final keys get merged in the same order:

;; spec
(dict {:name string?} {:age int?})

;; data
{:name "Ivan" :age 34}

You can override types if you need:

;; spec
(dict {:name string?}
      {:age int?}
      {:name int?})

;; data
{:name 42 :age 34}

By default, all the keys are required. To mark keys as optional, put the ^:opt metadata flag:

;; spec
(dict {:name string?}
      ^:opt {:age int?})

;; data OK
{:name "Ivan" :age 34}
{:name "Ivan"}

;; data ERR
{:name "Ivan" :age nil}

A dict can reference any spec:

(dict ::user-simple
      {:active :fields/boolean})

Conforming:

(s/def ::->int
  (s/conformer (fn [x]
                 (try
                   (Integer/parseInt x)
                   (catch Exception e
                     ::s/invalid)))))

;; spec
(dict {:value ::->int})

(s/conform spec {:value "123"})
{:value 123}

Unforming:

(s/def ::->int2
  (s/conformer (fn [x]
                 (try
                   (Integer/parseInt x)
                   (catch Exception e
                     ::s/invalid)))
               (fn [x]
                 (str x))))

;; spec
(dict {:value ::->int2})

(s/unform spec (s/conform spec {:value "123"}))
{:value "123"}

Strict version of a dict which fails when extra keys were passed:

;; spec
(dict* {:name string?
        :age int?}
       ^:opt {:active boolean?})

;; data OK
{:name "test" :age 34}
{:name "test" :age 34 :active true}

;; data ERR
{:name "test" :age 34 :extra "aa"}
{:name "test" :age 34 :active true :extra "aa"}

Generators:


;; spec
(dict {:name #{"Ivan" "Juan" "Iogann"}
       :age int?})


(gen/generate (s/gen spec))
{:name "Iogann" :age -2}

Explain:

;; spec
(dict {:name ::some-name
       :age int?})

;; not a map
(s/explain-data spec 123)

;; problem
{:reason "not a map"
 :path []
 :pred clojure.core/map?
 :val 123
 :via []
 :in []}

;; missing key
(s/explain-data spec {:age 34})

;; problem
{:reason "missing key"
 :val nil
 :pred (clojure.core/contains? #{:age :name} :name)
 :path [:name]
 :via [:spec-dict-test/some-name]
 :in [:name]}

;; wrong value
(s/explain-data spec {:name 123 :age 43})

;; problem
{:reason "spec failure"
 :val 123
 :pred clojure.core/string?
 :path [:name]
 :via [:spec-dict-test/some-name]
 :in [:name]}

Explain for a strict version:

;; spec
(dict* {:name string?
        :age int?})

;; extra key in a strict dict
(s/explain-data spec {:name "Ivan" :age 34 :extra true})

;; problem

{:reason "extra keys"
 :path []
 :pred (clojure.set/subset? #{:age :name :extra} #{:age :name})
 :val {:name "Ivan" :age 34 :extra true}
 :via []
 :in []}