In clojure.spec, how to declare all valid keys in a map

2,410 views
Skip to first unread message

David Goldfarb

unread,
Sep 20, 2016, 8:38:10 AM9/20/16
to Clojure
In clojure.spec, how can I declare a map that accepts only certain keys?  

{::a 1 ::b 2 ::BAD 3} does conform to (s/keys :req :req [::a ::b]), but I want a spec that will be bothered by ::BAD or any other undeclared key.

My use case: I am introducing spec to some legacy code, and I want to be warned if I have failed to specify some elements that may appear in my map.


Question 2:

 So, assuming that this is not possible currently, I brute-forced it with:

(defn- key-checker [valid-keys]
  (fn [map-to-check]
    (empty? (clojure.set/difference (into #{} (keys map-to-check)) valid-keys))))

(s/def ::my-map (s/and (s/keys :req [::a ::b])  (key-checker #{::a ::b})))


Ignoring the ugly, and easily fixable, smell of the duplicated set of keys, this has a bigger problem:

If the predicate fails, the error that assert gives me is "{... big ugly map ...} fails predicate: (key-checker #{::a ::b})" with no easy way for the viewer to see which key failed. Can I somehow hook into the explain mechanism to give a more useful message?

Beau Fabry

unread,
Sep 20, 2016, 12:38:47 PM9/20/16
to Clojure
boot.user=> (s/def ::my-map (s/and (s/keys :req [::a ::b])  (s/map-of #{::a ::b} any?)))
boot.user=> (s/explain ::my-map {::a 1 ::b 2 ::BAD 3})
In: [:boot.user/BAD 0] val: :boot.user/BAD fails spec: :boot.user/my-map at: [0] predicate: #{:boot.user/a :boot.user/b}

Seems better

Alex Miller

unread,
Sep 20, 2016, 2:47:43 PM9/20/16
to Clojure
For stuff like this s/merge is probably preferable to s/and (when combining map specs) - the difference being that merge does not flow conformed results, will combine all failures, and that gen can work better in some cases.

(s/def ::a int?)
(s/def ::b string?)  ;; changed for this example
(s/explain ::my-map {::a 1 ::b 2 ::BAD 3})
In: [:user/b] val: 2 fails spec: :user/b at: [:user/b] predicate: string?

;; vs:

(s/def ::my-map2 (s/merge (s/keys :req [::a ::b])  (s/map-of #{::a ::b} any?)))
(s/explain ::my-map2 {::a 1 ::b 2 ::BAD 3})
In: [:user/b] val: 2 fails spec: :user/b at: [:user/b] predicate: string?
In: [:user/BAD 0] val: :user/BAD fails spec: :user/my-map2 at: [0] predicate: #{:user/a :user/b}

^^ Note you get *both* failures here - both bad attribute value AND the invalid key vs the prior one where you only get the first failure.

David Goldfarb

unread,
Sep 21, 2016, 4:08:25 AM9/21/16
to Clojure
Nice, thanks. I had not thought to use map-of for this. And, s/merge certainly helps too.

The only remaining issue for me is that this requires supplying the list of keys twice.
AI think this case is general enough that it is worth extending the s/keys macro to support: (s/keys :req [::a ::b] :allow-other-keys false)

Or, if is is objectionable to have a keyword default to true when not supplied, perhaps: (s/keys :req [::a ::b] :strict-keys true)

Alistair Roche

unread,
Sep 25, 2016, 3:13:12 AM9/25/16
to Clojure
Yeah, my team and I were initially surprised at the lack of a built-in option for this to s/keys, but TBH it's been an unusual use case so far, and Alex / Beau's solutions don't seem particularly onerous despite the repetition.

I suppose if you're using it all over the place you could write a macro like this:

(defmacro only-keys
[& {:keys [req req-un opt opt-un] :as args}]
`(s/and (s/keys ~@(apply concat (vec args)))
(s/map-of ~(set (concat req
(map (comp keyword name) req-un)
opt
(map (comp keyword name) opt-un)))
any?)))


(please feel free to suggest a neater way!)

Cheers,

Alistair Roche

unread,
Sep 25, 2016, 3:15:12 AM9/25/16
to Clojure
Whoops, that should be:

(defmacro only-keys
[& {:keys [req req-un opt opt-un] :as args}]
  `(s/merge (s/keys ~@(apply concat (vec args)))

(s/map-of ~(set (concat req
(map (comp keyword name) req-un)
opt
(map (comp keyword name) opt-un)))
any?)))

刘鑫

unread,
Apr 5, 2019, 1:25:49 AM4/5/19
to Clojure
Maybe we can do a little more:

(defmacro limit-keys
[& {:keys [req req-un opt opt-un only] :as args}]
(if only

`(s/merge (s/keys ~@(apply concat (vec args)))
(s/map-of ~(set (concat req
(map (comp keyword name) req-un)
opt
(map (comp keyword name) opt-un)))
any?))
`(s/keys ~@(apply concat (vec args))))) 

Test Code like this: 

(def email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$")
(s/def ::email-type (s/and string? #(re-matches email-regex %)))

(def phone-regex #"^[0-9]{7,11}$")
(s/def ::phone (s/and string? #(re-matches phone-regex %)))

(s/def ::acctid int?)
(s/def ::first-name string?)
(s/def ::last-name string?)
(s/def ::email ::email-type)

(s/def ::person (limit-keys :req [::first-name ::last-name ::email]
:opt [::phone]))

(s/def ::x-person (limit-keys :req [::first-name ::last-name ::email]
:opt [::phone]
:only true))
(deftest smart-test
(is (= true (s/valid? ::person
{::first-name "Bugs"
::last-name "Bunny"
::email "bu...@example.com"})))
(is (= true (s/valid? ::person
{::first-name "Bugs"
::last-name "Bunny"
::email "bu...@example.com"
::phone "1000000"})))
(is (= false (s/valid? ::x-person
{::first-name "Bugs"
::last-name "Bunny"
::email "bu...@example.com"
::x-phone "1000000"})))
(is (= true (s/valid? ::person
{::first-name "Bugs"
::last-name "Bunny"
::email "bu...@example.com"
::x-phone "1000000"}))))

在 2016年9月25日星期日 UTC+8下午3:15:12,Alistair Roche写道:
Reply all
Reply to author
Forward
0 new messages