Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Data-driven Schemas for Clojure/Script and [babashka](#babashka).
- Tools for [Programming with Schemas](#programming-with-schemas)
- [Parsing](#parsing-values) and [Unparsing](#unparsing-values) values
- [Enumeration](#enumeration-schemas), [Sequence](#sequence-schemas), [Vector](#vector-schemas), and [Set](#set-schemas) Schemas
- [Persisting schemas](#persisting-schemas), even [function schemas](#serializable-functions)
- [Persisting schemas](#persisting-schemas), even [function schemas](#serializable-functions) and [conditionals](#conditional-schemas)
- Immutable, Mutable, Dynamic, Lazy and Local [Schema Registries](#schema-registry)
- [Schema Transformations](#schema-Transformation) to [JSON Schema](#json-schema), [Swagger2](#swagger2), and [descriptions in english](#description)
- [Multi-schemas](#multi-schemas), [Recursive Schemas](#recursive-schemas) and [Default values](#default-values)
Expand Down Expand Up @@ -720,6 +720,37 @@ Use `:maybe` to express that an element should match some schema OR be `nil`:
;; => false
```

## Conditional schemas

`:if` can be used to express a simple if/when like conditional:

```clojure
(def habitable-planets [:if [:map [:planet [:= "Earth"]]]
[:map [:habitable [:= true]]]
[:map [:habitable [:= false]]]])

(m/validate habitable-planets {:planet "Earth" :habitable true})
;; => true
(m/validate habitable-planets {:planet "Earth" :habitable false})
;; => false
(m/validate habitable-planets {:planet "Jupiter" :habitable true})
;; => false
(m/validate habitable-planets {:planet "Neptune" :habitable false})
;; => true
```
Nesting and more complex schemas can be used as well:
```clojure
(def gas-giants [:if [:map [:planet [:enum "Jupiter" "Saturn"]]]
[:map [:size [:= :massive]]]
habitable-planets])

(m/validate gas-giants {:planet "Jupiter"})
;; => false
(m/validate gas-giants {:planet "Jupiter" :size :massive})
;; => true
(m/validate gas-giants {:planet "Earth" :habitable true})
;; => true
```
## Error messages

Detailed errors with `m/explain`:
Expand Down Expand Up @@ -2975,6 +3006,31 @@ Full override with `:json-schema` property:
;; => {:type "file"}
```

#### Conditional support

[If-Then-Else conditional](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) is
supported via `:if`
```clojure
(json-schema/transform
[:if
[:map [:kikka [:= 6]]]
[:map [:temppu [:= :kukka]]]
[:map [:lahjus [:= :suklaa]]]])
;; => {:if {:properties {:kikka {:const 6}}, :required [:kikka]},
;; :then {:properties {:temppu {:const :kukka}}, :required [:temppu]}
;; :else {:properties {:lahjus {:const :suklaa}}, :required [:lahjus]}}
```
For conformance you may also produce `:type "object"` into maps if required with `{:omit-condition-type false}`.
`Else` branch is also optional.
```clojure
(json-schema/transform
[:if {:omit-condition-type false}
[:map [:juupas [:= true]]]
[:map [:eipas [:= false]]]])
;; => {:if {:type "object", :properties {:juupas {:const true}}, :required [:juupas]}
;; :then {:type "object", :properties {:eipas {:const false}}, :required [:eipas]}}
```

### Swagger2

Transforming Schemas into [Swagger2 Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md):
Expand Down
98 changes: 97 additions & 1 deletion src/malli/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -3023,6 +3023,102 @@
:re-transformer (fn [_ children] (apply re/alt-transformer children))
:re-min-max (fn [_ children] (reduce -re-alt-min-max {:max 0} (-vmap last children)))})})

(defn -if-conditional-schema
"Malli schema for JSON Schema's If-Then-Else conditional support.

Usage:
```
[::jse/if [:map [:x [:= true]]] ; IF
[:map [:y int?]] ; THEN
[:map [:y string?]]] ; ELSE
```
Validates the same as:
```
[:or [:and [:map [:x [:= true]]] ; IF
[:map [:y int?]]] ; THEN
[:and [:not [:map [:x [:= true]]]] ; NOT IF
[:map [:y string?]]]] ; ELSE
```

The `ELSE` branch can be omitted for `when`-like behavior."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more like logical implication as written. This is the opposite of what I expected. For example [:or [:if S T] [:if S' T']] would never hit the second schema.

Additionally, the equivalent schema to [:if S T] is actually [:or [:and S T] :any], so there's some internal inconsistency here.

This might need more time to bake, I propose we support 2 args later and start with 3. I understand JSON Schema maps missing branches to true but this doesn't map nicely to Clojure's semantics.

[]
^{:type ::into-schema}
(reify IntoSchema
(-type [_] :if)
(-type-properties [_])
(-properties-schema [_ _])
(-children-schema [_ _])
(-into-schema [parent {:keys [tags] :as properties} children options]
(-check-children! ::if properties children 2 3)
(let [condition' (schema (nth children 0) options)
then' (schema (nth children 1) options)
else' (when (= 3 (count children))
(schema (nth children 2) options))
condition-v (validator condition')
equivalent-schema' (schema (if else'
[:or
[:and condition' then']
[:and [:not condition'] else']]
[:and condition' then'])
options)]
^{:type ::schema}
(reify
Schema
(-validator [this]
(let [then-v (validator then')
else-v (when else' (validator else'))]
(fn [value]
(if (condition-v value)
(then-v value)
(if else-v (else-v value) true)))))
(-explainer [this path]
(let [then-explainer (-explainer then' (conj path 'then))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -explainer seems to do extra work. It's enough to simply extend the path here instead of then associng :branch later. And I suggest using keywords as the path elements and implementing LensSchema.

else-explainer (when else' (-explainer else' (conj path 'else)))]
(fn explain [x in acc]
(if (condition-v x)
(let [before (count acc)
acc' (then-explainer x in acc)]
(if (> (count acc') before)
(into (subvec (vec acc') 0 before)
(map #(assoc % :branch 'then))
(subvec (vec acc') before))
acc'))
(if else-explainer
(let [before (count acc)
acc' (else-explainer x in acc)]
(if (> (count acc') before)
(into (subvec (vec acc') 0 before)
(map #(assoc % :branch 'else))
(subvec (vec acc') before))
acc'))
acc)))))
(-parser [_]
(let [then-p (-parser then')
else-p (when else' (-parser else'))]
(fn [x]
(if (condition-v x)
(then-p x)
(if else-p (else-p x) x)))))
(-unparser [_]
(let [then-u (-unparser then')
else-u (when else' (-unparser else'))]
(fn [x]
(if (condition-v x)

@frenchy64 frenchy64 May 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is right. x here is potentially parsed so it's meaningless to test the condition validator. For example, consider [:if :int [:orn [:int :int]]]. It's now a tag at this point, so will always fail the :int validator.

I think there's a few options to choose here:

  1. we return a tagged result from -parser similar to :orn
  2. we ensure :if never transforms the parsed value by using -simple-parser
  3. we try each unparser in turn similar to :or

(then-u x)
(if else-u (else-u x) x)))))
(-transformer [this transformer method options]
(-transformer equivalent-schema' transformer method options))
(-walk [this walker path options]
(-walk-leaf this walker path options))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:if is not a leaf.

(-properties [this] properties)
(-options [this] options)
(-children [this] children)
(-parent [this] parent)
(-form [this] form))))))

@frenchy64 frenchy64 May 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the correct implementation here for ParserInfo.

ParserInfo
(-parser-info [_ opts]
  {:simple-parser (and (:simple-parser (-parser-info then' opts))
                       (if else' (:simple-parser (-parser-info else' opts))  true))})

There are some other useful methods missed that are implemented in the surrounding schemas.

EDIT: actually this depends on which implementation of -parser we use so let's defer this discussion until that is resolved.


(defn conditional-schemas []
{:if (-if-conditional-schema)})

(defn base-schemas []
{:and (-and-schema)
:andn (-andn-schema)
Expand Down Expand Up @@ -3050,7 +3146,7 @@
::schema (-schema-schema {:raw true})})

(defn default-schemas []
(merge (predicate-schemas) (class-schemas) (comparator-schemas) (type-schemas) (sequence-schemas) (base-schemas)))
(merge (predicate-schemas) (class-schemas) (comparator-schemas) (type-schemas) (sequence-schemas) (conditional-schemas) (base-schemas)))

(def default-registry
(let [strict #?(:cljs (identical? mr/mode "strict")
Expand Down
14 changes: 14 additions & 0 deletions src/malli/generator.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,18 @@
(defn -qualified-symbol-gen [schema]
(-qualified-ident-gen schema symbol gen/symbol qualified-symbol? gen/symbol-ns))

(defn -if-conditional-gen [schema options]
(let [[condition then else] (m/children schema)
condition-s (m/schema condition options)
then-s (m/schema then options)
merged-then (mu/merge condition-s then-s options)
then-gen (generator merged-then options)
else-gen (when else
(gen-such-that schema (m/validator schema) (generator (m/schema else options) options)))]
(if else-gen
(gen/one-of [then-gen else-gen])
then-gen)))

(defn- gen-elements [es]
(if (= 1 (count es))
(gen/return (first es))
Expand Down Expand Up @@ -447,6 +459,8 @@
(defmethod -schema-generator :+ [schema options] (-+-gen schema options))
(defmethod -schema-generator :repeat [schema options] (-repeat-gen schema options))

(defmethod -schema-generator :if [schema options] (-if-conditional-gen schema options))

;;
;; Creating a generator by different means, centralized under [[-create]]
;;
Expand Down
7 changes: 7 additions & 0 deletions src/malli/json_schema.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@
(defmethod accept :union [_ schema _ {::keys [transform] :as options}] (transform (m/deref schema) options))
(defmethod accept :select-keys [_ schema _ {::keys [transform] :as options}] (transform (m/deref schema) options))

(defmethod accept :if [_ schema _ {::keys [transform] :as options}]
(let [omit-type? (get (m/properties schema) :omit-condition-type true)
omitter (fn [m] (if omit-type? (dissoc m :type) m))
[condition then else] (mapv #(transform % options) (m/children schema))]
(cond-> {:if (omitter condition) :then (omitter then)}
else (assoc :else (omitter else)))))

(defn- -json-schema-walker [schema _ children options]
(let [p (merge (m/type-properties schema) (m/properties schema))]
(or (get p :json-schema)
Expand Down
29 changes: 29 additions & 0 deletions test/malli/core_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -3697,3 +3697,32 @@
(is (= @count-into-schemas 3)) ;; was 6
(is (m/coerce ConsCell [1 [2 [3 [4 [1 [2 [3 [4 nil]]]]]]]]))
(is (= @count-into-schemas 3)))) ;; was 10

(deftest if-conditional-schema-test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good test, though it doesn't exercise the 2-arity form at all!

(testing "validation equivalence"
(let [condition [:map [:x [:= true]]]
then [:map [:y int?]]
else [:map [:y string?]]
jse-schema [:if condition
then
else]
equivalent-schema [:or [:and condition then]
[:and [:not condition] else]]
valid-when-true {:x true :y 42}
valid-when-false {:x false :y "hello"}
invalid-when-true {:x true :y "oops"}
invalid-when-false {:x false :y 99}]
(testing ":if validates with matching JSON Schema If-Then-Else semantics"
(is (true? (m/validate jse-schema valid-when-true)))
(is (true? (m/validate jse-schema valid-when-false)))
(is (false? (m/validate jse-schema invalid-when-true)))
(is (false? (m/validate jse-schema invalid-when-false))))
(testing "matches the equivalent :or/:and malli schema"
(is (= (m/validate equivalent-schema valid-when-true)
(m/validate jse-schema valid-when-true)))
(is (= (m/validate equivalent-schema valid-when-false)
(m/validate jse-schema valid-when-false)))
(is (= (m/validate equivalent-schema invalid-when-true)
(m/validate jse-schema invalid-when-true)))
(is (= (m/validate equivalent-schema invalid-when-false)
(m/validate jse-schema invalid-when-false)))))))
26 changes: 26 additions & 0 deletions test/malli/error_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -955,3 +955,29 @@
(is (= ["invalid type"] (me/humanize (m/explain [:map] []))))
(is (= [["invalid type"]] (me/humanize (m/explain [:vector [:map]] [[]]))))
(is (= [["invalid type"]] (me/humanize (m/explain [:vector [:fn {:error/path [-1]} (comp int? :foo)]] [[]])))))

(deftest if-conditional-test
(let [humanizer (fn [v]
(-> [:map
[:if-then-else {:optional true}
[:if
[:map [:condition [:= true]]]
[:map [:result int?]]
[:map [:result string?]]]]
[:if-then {:optional true}
[:if
[:map [:condition [:= true]]]
[:map [:result keyword?]]]]]
(m/explain v)
(me/humanize)))]
; successes
(is (nil? (humanizer {:if-then-else {:condition true :result 123}})))
(is (nil? (humanizer {:if-then-else {:condition false :result "hello"}})))
(is (nil? (humanizer {:if-then {:condition true :result :hello}})))
(is (nil? (humanizer {:if-then {:condition false}})))
; failures
(is (= {:if-then-else {:result ["should be an int"]}} (humanizer {:if-then-else {:condition true :result true}})))
(is (= {:if-then-else {:result ["should be a string"]}} (humanizer {:if-then-else {:condition false :result true}})))
(is (= {:if-then {:result ["should be a keyword"]}} (humanizer {:if-then {:condition true :result true}})))
; Listed for brevity. This is not actually a failure, as missing else branch evaluates as valid/true.
(is (= nil (humanizer {:if-then {:condition false}})))))
14 changes: 14 additions & 0 deletions test/malli/generator_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -1140,3 +1140,17 @@

(deftest empty?-generator-test
(is (every? empty? (mg/sample empty?))))

(deftest if-conditional-generator-test
(let [if-then-else [:if
[:map [:x [:= true]]]
[:map [:y int?]]
[:map [:y string?]]]
generated (mg/sample if-then-else {:size 20})]
(is (every? #(m/validate if-then-else %) generated) "Expected all generated values to validate against the schema")
(is (some #(true? (:x %)) generated) "Expected some generated values with x=true (then-branch)")
(is (some #(not (true? (:x %))) generated) "Expected some generated values without x (else-branch)"))
(let [if-then [:if
[:map [:active [:= true]]]
[:map [:name string?]]]]
(is (every? #(m/validate if-then %) (mg/sample if-then)))))
Loading