Search code examples
clojuredestructuringclojure.spec

How can I spec a hybrid map?


After writing this answer, I was inspired to try to specify Clojure's destructuring language using spec:

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

(s/def ::binding (s/or :sym ::sym :assoc ::assoc :seq ::seq))

(s/def ::sym (s/and simple-symbol? (complement #{'&})))

The sequential destructuring part is easy to spec with a regex (so I'm ignoring it here), but I got stuck at associative destructuring. The most basic case is a map from binding forms to key expressions:

(s/def ::mappings (s/map-of ::binding ::s/any :conform-keys true))

But Clojure provides several special keys as well:

(s/def ::as ::sym)
(s/def ::or ::mappings)

(s/def ::ident-vec (s/coll-of ident? :kind vector?))
(s/def ::keys ::ident-vec)
(s/def ::strs ::ident-vec)
(s/def ::syms ::ident-vec)

(s/def ::opts (s/keys :opt-un [::as ::or ::keys ::strs ::syms]))

How can I create an ::assoc spec for maps that could be created by merging together a map that conforms to ::mappings and a map that conforms to ::opts? I know that there's merge:

(s/def ::assoc (s/merge ::opts ::mappings))

But this doesn't work, because merge is basically an analogue of and. I'm looking for something that's analogous to or, but for maps.


Solution

  • You can spec hybrid maps using an s/merge of s/keys and s/every of the map as tuples. Here's a simpler example:

    (s/def ::a keyword?)
    (s/def ::b string?)
    (s/def ::m
      (s/merge (s/keys :opt-un [::a ::b])
               (s/every (s/or :int (s/tuple int? int?)
                              :option (s/tuple keyword? any?))
                        :into {})))
    
    (s/valid? ::m {1 2, 3 4, :a :foo, :b "abc"}) ;; true
    

    This simpler formulation has several benefits over a conformer approach. Most importantly, it states the truth. Additionally, it should generate, conform, and unform without further effort.