Does Clojure spec supports Records?

tl;dr - if not that’s ok but if it does then I want to show you strange behavior of using records and collections within spec/or (I haven’t tested yet if it is the same for other combining macros)

Ok, It’s hard to explain in just few words what is this strange behavior so I think it’s best to show you an example

Let’s say we have a simple spec definition of person:

(s/def ::name string?)
(s/def ::age pos-int?)
(s/def ::person
  (s/keys :req [::name ::age]))
(s/def ::people
  (s/coll-of ::person))

definition of record Err which we’ll use to represent our error

(defrecord Err [msg])

and some results definition:
one can be either person or error:

(s/def ::person-result
  (s/or :ok ::person, :err any?))

and another is either people (coll-of person) or error:

(s/def ::people-result
  (s/or :ok ::people
        :err any?))

:err is any? because for now it really doesn’t matter what it actually is, result is the same.

Now we want to validate it (all data in this post will be correct representations of their specs).
First with just one person:

(let [person-ok {::name "John", ::age 42}
      person-err (->Err "foo")]
  (s/valid? ::person-result person-ok) ; returns true
  (s/valid? ::person-result person-err) ; returns true
)

everything works fine here. Both person-ok and person-err are correct representations of ::person-result spec (mostly because :err is any? so it’ll accept anything).

Magic starts when we want to validate people which is collection.

(let [people-ok []
      people-err (->Err "foo")]
  (s/valid? ::people-result people-ok) ; returns true
  (s/valid? ::people-result people-err) ; throws exception
  ; Unhandled java.lang.UnsupportedOperationException
  ; Can't create empty: user.Err
)

To make things better everything works fine if in our ::people-result spec we first define :err.
any? is no longer enough to validate our error so let’s create a spec definition for it first:

(s/def ::msg string?)
(s/def ::err
  (s/keys :req-un [::msg]))
(s/valid? ::err (->Err "foo")) ; returns true - looks fine

now we can move to working spec:

; :err is definied first unlike in ::people-result where :ok was first
(s/def ::people-result-2
  (s/or :err ::err
        :ok ::people))

(let [people-ok []
       people-err (->Err "foo")]
  (s/valid ::people-result-2 people-ok) ; returns true
  (s/valid ::people-result-2 people-err) ; returns true
)

and again everything works.

It looks like a bug for me

I didn’t actually read all that, but records have effectively unqualified keys from spec’s perspective so you should only spec them with :req-un / :opt-un. There is a section in Clojure - spec Guide that talks about this.

Reading more closely, this may actually be a weird corner case that is a known issue: [CLJ-1975] [spec] clojure.spec attempts to make `empty` records - JIRA

See the workaround in the comments for now.

1 Like