Clojure Spec - Issue with spec/or nested in spec/and
Asked Answered
H

2

5

I've recently been trying out Clojure Spec and ran into an unexpected error message. I've figured out that if you have a spec/or nested in a spec/and then the spec functions, after the spec/or branch, get passed a conformed value rather than the top level value.

You can see this in the printed value of "v" here (contrived example):

(spec/valid? (spec/and (spec/or :always-true (constantly true))
                       (fn [v]
                         (println "v:" v)
                         (constantly true)))
         nil)
v: [:always-true nil]
=> true

I think this may be intentional from the doc string of spec/and:

Takes predicate/spec-forms, e.g.

(s/and even? #(< % 42))

Returns a spec that returns the conformed value. Successive conformed values propagate through rest of predicates.

But this seems counterintuitive to me as it would hamper reuse of spec predicates, because they'd need to be written to accept "[<or branch> <actual value>]".

Things get even worse if you have multiple spec/or branches:

(spec/valid? (spec/and (spec/or :always-true (constantly true))
                       (spec/or :also-always-true (constantly true))
                       (fn [v]
                         (println "v:" v)
                       (constantly true)))
         nil)
v: [:also-always-true [:always-true nil]]
=> true

Have I missed something fundamental here?

Headliner answered 24/6, 2018 at 8:0 Comment(0)
P
8

But this seems counterintuitive to me as it would hamper reuse of spec predicates

IMO the alternatives to these behaviors are less appealing:

  • Discard s/or's conformed tags by default. We can always discard it if we want, but we wouldn't want clojure.spec to make that decision for us. Spec assumes we want to know which s/or branch matched.
  • Don't flow conformed values in s/and, at the expense of spec/predicate composability.

Luckily we can discard the s/or tags if necessary. Here are two options:

  • Wrap the s/or in s/noncomforming. Thanks to glts' comment below reminding me about this (undocumented) function!

    (s/valid?
      (s/and
        (s/nonconforming (s/or :s string? :v vector?))
        empty?)
      "")
    => true
    
  • s/and the s/or specs with a s/conformer that discards the tag.

    (s/valid?
      (s/and
        (s/and (s/or :s string? :v vector?)
               ;; discard `s/or` tag here
               (s/conformer second))
        empty?)
      [])
    => true
    

    If you often needed this, you could reduce boilerplate with a macro:

    (defmacro dkdc-or [& key-pred-forms]
      `(s/and (s/or ~@key-pred-forms) (s/conformer second)))
    

Things get even worse if you have multiple spec/or branches

If you're writing a spec for data that allows for alternatives (e.g. s/or, s/alt), and you're "flowing" valid alternatives into subsequent predicates (s/and), IMO it's more generally useful to have that knowledge in subsequent predicates. I'd be interested in seeing a more realistic use case for this type of spec, because there might be a better way to spec it.

Phenylketonuria answered 24/6, 2018 at 11:18 Comment(3)
You can also use s/nonconforming instead of the conformer.Benedic
Taylor it is a pleasure to read your answers, always real answers with a rationale instead of the quick and dirty piece of code that might solve the question but doesn't contribute to understanding. Thanks!Rescission
Thanks for a taking the time to write a great answer. I understand your points but ultimately I can see this tripping up a lot of people who start small, building up spec's with simple predicates and then, as I found, they will stop working when you try to combine them with the and/or operations. Perhaps this is a case for simple and/or's that would work as you'd expect with simple predicates and a more advanced option for cases where the conformed result is needed?Headliner
B
0

Complementary comment: the behavior of Spec spotted by the question means that you can very well have (and (s/valid? ::spec-1 v) (s/valid? ::spec2) v) but not (s/valid? (s/and ::spec-1 ::spec-2) v), due to conforming from ::spec-1.

You might understandably find this behavior of s/and surprising (not to mention insane). Note however that you will have (s/valid? (s/and (s/nonconforming ::spec-1) ::spec-2) v), as mentioned by the accepted answer.

The corollary is that when using s/and, ask yourself whether you need s/conforming. This piece of advice might be a worthwhile addition to the docstring of s/and.

Bills answered 25/10, 2022 at 12:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.