How can I spec a hybrid map?
Asked Answered
R

2

8

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.

Roadblock answered 1/7, 2016 at 18:0 Comment(0)
E
5

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.

Elevator answered 11/8, 2016 at 20:12 Comment(0)
I
1

You can use s/conformer as an intermediate step in s/and to transform your map to the form that’s easy to validate:

(s/def ::assoc
  (s/and
    map?
    (s/conformer #(array-map
                    ::mappings (dissoc % :as :or :keys :strs :syms)
                    ::opts     (select-keys % [:as :or :keys :strs :syms])))
    (s/keys :opt [::mappings ::opts])))

That will get you from e.g.

{ key :key
  :as name }

to

{ ::mappings { key :key }
  ::opts     { :as name } }
Interminable answered 5/7, 2016 at 14:45 Comment(1)
Thanks! I was sure there was something like this that could be used. It seems pretty ugly, though; I hope that a more elegant solution is added in the future.Roadblock

© 2022 - 2024 — McMap. All rights reserved.