malli

Data-driven Schemas for Clojure/Script and babashka.
Metosin Open Source Status: Active. Stability: well matured alpha.
- Schema definitions as data
- Vector, Map and Lite syntaxes
- Validation and Value Transformation
- First class Error Messages with Spell Checking
- Generating values from Schemas
- Inferring Schemas from sample values and Destructuring.
- Tools for Programming with Schemas
- Parsing and Unparsing values
- Enumeration, Sequence, Vector, and Set Schemas
- Persisting schemas, even function schemas
- Immutable, Mutable, Dynamic, Lazy and Local Schema Registries
- Schema Transformations to JSON Schema, Swagger2, and descriptions in english
- Multi-schemas, Recursive Schemas and Default values
- Function Schemas with dynamic and static schema checking
- Visualizing Schemas with DOT and PlantUML
- Pretty development time errors
- Fast
Presentations:
Try the online demo, see also some 3rd Party Libraries.
Want to contribute? See the Development guide.
Hi! We are Metosin, a consulting company. These libraries have evolved out of the work we do for our clients.
We maintain & develop this project, for you, for free. Issues and pull requests welcome!
However, if you want more help using the libraries, or want us to build something as cool for you, consider our commercial support.
Motivation
We are building dynamic multi-tenant systems where data models should be first-class: they should drive the runtime value transformations, forms and processes. We should be able to edit the models at runtime, persist them and load them back from a database and over the wire, for both Clojure and ClojureScript. Think of JSON Schema, but for Clojure/Script.
Hasn’t the problem been solved (many times) already?
There is Schema, which is an awesome, proven and collaborative open-source project, and we absolutely love it. We still use it in many of our projects. The sad part: serializing & de-serializing schemas is non-trivial and there is no proper support on branching.
Spec is the de facto data specification library for Clojure. It has many great ideas, but it is opinionated with macros, global registry, and it doesn’t have any support for runtime transformations. Spec-tools was created to “fix” some of the things, but after five years of developing it, it’s still a kind of hack and not fun to maintain.
So, we decided to spin out our own library, which would do all the things we feel is important for dynamic system development. It’s based on the best parts of the existing libraries and several project-specific tools we have done over the years.
If you have expectations (of others) that aren’t being met, those expectations are your own responsibility. You are responsible for your own needs. If you want things, make them.
The library

Malli requires Clojure 1.11 or ClojureScript 1.11.51.
Malli is tested with the LTS releases Java 8, 11, 17, 21 and 25.
Quickstart
(require '[malli.core :as m])
(def UserId :string)
(def Address
[:map
[:street :string]
[:country [:enum "FI" "UA"]]])
(def User
[:map
[:id #'UserId]
[:address #'Address]
[:friends [:set {:gen/max 2} [:ref #'User]]]])
(require '[malli.generator :as mg])
(mg/generate User)
;{:id "AC",
; :address {:street "mf", :country "UA"},
; :friends #{{:id "1dm",
; :address {:street "8", :country "UA"},
; :friends #{}}}}
(m/validate User *1)
; => true
Syntax
Malli supports Vector, Map and Lite syntaxes.
Vector syntax
The default syntax uses vectors, inspired by hiccup:
type
[type & children]
[type properties & children]
Examples:
;; just a type (String)
:string
;; type with properties
[:string {:min 1, :max 10}]
;; type with properties and children
[:tuple {:title "location"} :double :double]
;; a function schema of :int -> :int
[:=> [:cat :int] :int]
[:-> :int :int]
Usage:
(require '[malli.core :as m])
(def non-empty-string
(m/schema [:string {:min 1}]))
(m/schema? non-empty-string)
;; => true
(m/validate non-empty-string "")
;; => false
(m/validate non-empty-string "kikka")
;; => true
(m/form non-empty-string)
;; => [:string {:min 1}]
Map syntax
Alternative map-syntax, similar to cljfx:
NOTE: For now, Map syntax in considered as internal, so don’t use it as a database persistency model.
;; just a type (String)
{:type :string}
;; type with properties
{:type :string
:properties {:min 1, :max 10}}
;; type with properties and children
{:type :tuple
:properties {:title "location"}
:children [{:type :double}
{:type :double}]}
;; a function schema of :int -> :int
{:type :=>
:input {:type :cat, :children [{:type :int}]}
:output :int}
{:type :->
:children [{:type :int} {:type :int}]}
Usage:
(def non-empty-string
(m/from-ast {:type :string
:properties {:min 1}}))
(m/schema? non-empty-string)
;; => true
(m/validate non-empty-string "")
;; => false
(m/validate non-empty-string "kikka")
;; => true
(m/ast non-empty-string)
;; => {:type :string
;; :properties {:min 1}}
Map-syntax is also called the Schema AST.
Why multiple syntaxes?
Malli started with just the Vector syntax. It’s really powerful and relatively easy to read, but not optimal for all use cases.
We introduced Map Syntax as we found out that the overhead of parsing large amount of vector-syntaxes can be a deal-breaker when running on slow single-threaded environments like Javascript on mobile phones. Map-syntax allows lazy and parseless Schema Creation.
We added Lite Syntax for simplified schema creation for special cases, like to be used with reitit coercion and for easy migration from data-specs.
Example Address schema
Following example schema is assumed in many of the following examples.
(def Address
[:map
[:id :string]
[:tags [:set :keyword]]
[:address
[:map
[:street :string]
[:city :string]
[:zip :int]
[:lonlat [:tuple :double :double]]]]])
Validation
Validating values against a schema:
;; with schema instances
(m/validate (m/schema :int) 1)
;; => true
;; with vector syntax
(m/validate :int 1)
;; => true
(m/validate :int "1")
;; => false
(m/validate [:= 1] 1)
;; => true
(m/validate [:enum 1 2] 1)
;; => true
(m/validate [:and :int [:> 6]] 7)
;; => true
(m/validate [:qualified-keyword {:namespace :aaa}] :aaa/bbb)
;; => true
;; optimized (pure) validation function for best performance
(def valid?
(m/validator
[:map
[:x :boolean]
[:y {:optional true} :int]
[:z :string]]))
(valid? {:x true, :z "kikka"})
;; => true
Schemas can have properties:
(def Age
[:and
{:title "Age"
:description "It's an age"
:json-schema/example 20}
:int [:> 18]])
(m/properties Age)
;; => {:title "Age"
;; :description "It's an age"
;; :json-schema/example 20}
Maps are open by default:
(m/validate
[:map [:x :int]]
{:x 1, :extra "key"})
;; => true
Maps can be closed with :closed property:
(m/validate
[:map {:closed true} [:x :int]]
{:x 1, :extra "key"})
;; => false
Maps keys are not limited to keywords:
(m/validate
[:map
["status" [:enum "ok"]]
[1 :any]
[nil :any]
[::a :string]]
{"status" "ok"
1 'number
nil :yay
::a "properly awesome"})
;; => true
Most core-predicates are mapped to Schemas:
(m/validate string? "kikka")
;; => true
NOTE: Predicate Schemas do not cover any schema properties, e.g. string? can’t be modified with properties like :min and :max. If you want to use the schema properties, use real schema types instead, e.g. :string over string?.
See the full list of default schemas.
Enumeration schemas
:enum schemas [:enum V1 V2 ...] represent an enumerated set of values V1 V2 ....
This mostly works as you’d expect, with values passing the schema if it is contained in the set and generators returning one of the values,
shrinking to the left-most value.
There are some special cases to keep in mind around syntax. Since schema properties can be specified with a map or nil, enumerations starting with
a map or nil must use slightly different syntax.
If your :enum does not have properties, you must provide nil as the properties.
[:enum nil {}] ;; singleton schema of {}
[:enum nil nil] ;; singleton schema of nil
If your :enum has properties, the leading map with be interpreted as properties, not an enumerated value.
[:enum {:foo :bar} {}] ;; singleton schema of {}, with properties {:foo :bar}
[:enum {:foo :bar} nil] ;; singleton schema of nil, with properties {:foo :bar}
In fact, these syntax rules apply to all schemas, but :enum is the most common schema where this is relevant so it deserves a special mention.
Qualified keys in a map
You can also use decomplected maps keys and values using registry references. References must be either qualified keywords or strings.
(m/validate
[:map {:registry {::id :int
::country :string}}
::id
[:name :string]
[::country {:optional true}]]
{::id 1
:name "kikka"})
;; => true
Homogeneous maps
Other times, we use a map as a homogeneous index. In this case, all our key-value
pairs have the same type. For this use case, we can use the :map-of schema.
(m/validate
[:map-of :string [:map [:lat number?] [:long number?]]]
{"oslo" {:lat 60 :long 11}
"helsinki" {:lat 60 :long 24}})
;; => true
Map with default schemas
Map schemas can define a special :malli.core/default key to handle extra keys:
(m/validate
[:map
[:x :int]
[:y :int]
[::m/default [:map-of :int :int]]]
{:x 1, :y 2, 1 1, 2 2})
;; => true
default branching can be arbitrarily nested:
(m/validate
[:map
[:x :int]
[::m/default [:map
[:y :int]
[::m/default [:map-of :int :int]]]]]
{:x 1, :y 2, 1 1, 2 2})
;; => true
Seqable schemas
The :seqable and :every schemas describe seqable? collections. They
differ in their handling of collections that are neither counted? nor indexed?, and their
parsers:
:seqable parses its elements but :every does not and returns the identical input, and
- valid unparsed
:seqable values lose the original collection type while :every
returns the identical input.
:seqable validates the entire collection, while :every checks only the
largest of :min, (inc :max), and (::m/coll-check-limit options 101), or
the entire collection if the input is counted? or indexed?.
;; :seqable and :every validate identically with small, counted, or indexed collections.
(m/validate [:seqable :int] #{1 2 3})
;; => true
(m/validate [:seqable :int] [1 2 3])
;; => true
(m/validate [:seqable :int] (sorted-set 1 2 3))
;; => true
(m/validate [:seqable :int] (range 1000))
;; => true
(m/validate [:seqable :int] (conj (vec (range 1000)) nil))
;; => false
(m/validate [:every :int] #{1 2 3})
;; => true
(m/validate [:every :int] [1 2 3])
;; => true
(m/validate [:every :int] (sorted-set 1 2 3))
;; => true
(m/validate [:every :int] (vec (range 1000)))
;; => true
(m/validate [:every :int] (conj (vec (range 1000)) nil))
;; => false
;; for large uncounted and unindexed collections, :every only checks a certain length
(m/validate [:seqable :int] (concat (range 1000) [nil]))
;; => false
(m/validate [:every :int] (concat (range 1000) [nil]))
;; => true
Sequence schemas
You can use :sequential to describe homogeneous sequential Clojure collections.
(m/validate [:sequential :any] (list "this" 'is :number 42))
;; => true
(m/validate [:sequential :int] [42 105])
;; => true
(m/validate [:sequential :int] #{42 105})
;; => false
Malli also supports sequence regexes (also called sequence expressions) like Seqexp and Spec.
The supported operators are :cat & :catn for concatenation / sequencing
(m/validate [:cat :string :int] ["foo" 0])
;; => true
(m/validate [:catn [:s :string] [:n :int]] ["foo" 0])
;; => true
:alt & :altn for alternatives
(m/validate [:alt :keyword :string] ["foo"])
;; => true
(m/validate [:altn [:kw :keyword] [:s :string]] ["foo"])
;; => true
and :?, :*, :+ & :repeat for repetition:
(m/validate [:? :int] [])
;; => true
(m/validate [:? :int] [1])
;; => true
(m/validate [:? :int] [1 2])
;; => false
(m/validate [:* :int] [])
;; => true
(m/validate [:* :int] [1 2 3])
;; => true
(m/validate [:+ :int] [])
;; => false
(m/validate [:+ :int] [1])
;; => true
(m/validate [:+ :int] [1 2 3])
;; => true
(m/validate [:repeat {:min 2, :max 4} :int] [1])
;; => false
(m/validate [:repeat {:min 2, :max 4} :int] [1 2])
;; => true
(m/validate [:repeat {:min 2, :max 4} :int] [1 2 3 4])
;; => true ; (:max is inclusive, as elsewhere in Malli)
(m/validate [:repeat {:min 2, :max 4} :int] [1 2 3 4 5])
;; => false
:catn and :altn allow naming the subsequences / alternatives
(m/explain
[:* [:catn [:prop :string] [:val [:altn [:s :string] [:b :boolean]]]]]
["-server" "foo" "-verbose" 11 "-user" "joe"])
;; => {:schema [:* [:catn [:prop :string] [:val [:altn [:s :string] [:b :boolean]]]]],
;; :value ["-server" "foo" "-verbose" 11 "-user" "joe"],
;; :errors ({:path [0 :val :s], :in [3], :schema :string, :value 11}
;; {:path [0 :val :b], :in [3], :schema :boolean, :value 11})}
while :cat and :alt just use numeric indices for paths:
(m/explain
[:* [:cat :string [:alt :string :boolean]]]
["-server" "foo" "-verbose" 11 "-user" "joe"])
;; => {:schema [:* [:cat :string [:alt :string :boolean]]],
;; :value ["-server" "foo" "-verbose" 11 "-user" "joe"],
;; :errors ({:path [0 1 0], :in [3], :schema :string, :value 11}
;; {:path [0 1 1], :in [3], :schema :boolean, :value 11})}
As all these examples show, the sequence expression (seqex) operators take any non-seqex child schema to
mean a sequence of one element that matches that schema. To force that behaviour for
a seqex child :schema can be used:
(m/validate
[:cat [:= :names] [:schema [:* :string]] [:= :nums] [:schema [:* number?]]]
[:names ["a" "b"] :nums [1 2 3]])
;; => true
;; whereas
(m/validate
[:cat [:= :names] [:* :string] [:= :nums] [:* number?]]
[:names "a" "b" :nums 1 2 3])
;; => true
Although a lot of effort has gone into making the seqex implementation fast
(require '[clojure.spec.alpha :as s])
(require '[criterium.core :as cc])
(let [valid? (partial s/valid? (s/* int?))]
(cc/quick-bench (valid? (range 10)))) ; Execution time mean : 27µs
(let [valid? (m/validator [:* int?])]
(cc/quick-bench (valid? (range 10)))) ; Execution time mean : 2.7µs
it is always better to use less general tools whenever possible:
(let [valid? (partial s/valid? (s/coll-of int?))]
(cc/quick-bench (valid? (range 10)))) ; Execution time mean : 1.8µs
(let [valid? (m/validator [:sequential int?])]
(cc/quick-bench (valid? (range 10)))) ; Execution time mean : 0.12µs
Vector schemas
You can use :vector to describe homogeneous Clojure vectors.
(m/validate [:vector :int] [1 2 3])
;; => true
(m/validate [:vector :int] (list 1 2 3))
;; => false
A :tuple schema describes a fixed length Clojure vector of heterogeneous elements:
(m/validate [:tuple :keyword :string number?] [:bing "bang" 42])
;; => true
To create a vector schema based on a seqex, use :and.
;; non-empty vector starting with a keyword
(m/validate [:and [:cat :keyword [:* :any]]
vector?]
[:a 1])
;; => true
(m/validate [:and [:cat :keyword [:* :any]]
vector?]
(:a 1))
;; => false
Note: To generate values from a vector seqex, see :and generation.
Set schemas
You can use :set to describe homogeneous Clojure sets.
(m/validate [:set :int] #{42 105})
;; => true
(m/validate [:set :int] #{:a :b})
;; => false
String schemas
Using a predicate:
(m/validate string? "kikka")
Using :string Schema:
(m/validate :string "kikka")
;; => true
(m/validate [:string {:min 1, :max 4}] "")
;; => false
Using regular expressions:
(m/validate #"a+b+c+" "abbccc")
;; => true
;; :re with string
(m/validate [:re ".{3,5}"] "abc")
;; => true
;; :re with regex
(m/validate [:re #".{3,5}"] "abc")
;; => true
;; NB: re-find semantics
(m/validate [:re #"\d{4}"] "1234567")
;; => true
;; anchor with ^...$ if you want to strictly match the whole string
(m/validate [:re #"^\d{4}$"] "1234567")
;; => false
Maybe schemas
Use :maybe to express that an element should match some schema OR be nil:
(m/validate [:maybe :string] "bingo")
;; => true
(m/validate [:maybe :string] nil)
;; => true
(m/validate [:maybe :string] :bingo)
;; => false
Fn schemas
:fn allows any predicate function to be used:
(def my-schema
[:and
[:map
[:x :int]
[:y :int]]
[:fn (fn [{:keys [x y]}] (> x y))]])
(m/validate my-schema {:x 1, :y 0})
;; => true
(m/validate my-schema {:x 1, :y 2})
;; => false
Error messages
Detailed errors with m/explain:
(m/explain
Address
{:id "Lillan"
:tags #{:artesan :coffee :hotel}
:address {:street "Ahlmanintie 29"
:city "Tampere"
:zip 33100
:lonlat [61.4858322, 23.7854658]}})
;; => nil
(m/explain
Address
{:id "Lillan"
:tags #{:artesan "coffee" :garden}
:address {:street "Ahlmanintie 29"
:zip 33100
:lonlat [61.4858322, nil]}})
;; => {:schema [:map
;; [:id :string]
;; [:tags [:set :keyword]]
;; [:address [:map
;; [:street :string]
;; [:city :string]
;; [:zip :int]
;; [:lonlat [:tuple :double :double]]]]],
;; :value {:id "Lillan",
;; :tags #{:artesan :garden "coffee"},
;; :address {:street "Ahlmanintie 29"
;; :zip 33100
;; :lonlat [61.4858322 nil]}},
;; :errors ({:path [:tags 0]
;; :in [:tags 0]
;; :schema :keyword
;; :value "coffee"}
;; {:path [:address :city],
;; :in [:address :city],
;; :schema [:map
;; [:street :string]
;; [:city :string]
;; [:zip :int]
;; [:lonlat [:tuple :double :double]]],
;; :type :malli.core/missing-key}
;; {:path [:address :lonlat 1]
;; :in [:address :lonlat 1]
;; :schema :double
;; :value nil})}
Under :errors, you get a list of errors with the following keys:
:path, error location in Schema
:in, error location in value
:schema, schema in error
:value, value in error
(def Schema [:map [:x [:maybe [:tuple :string]]]])
(def value {:x [1]})
(def error (-> Schema
(m/explain value)
:errors
first))
error
;{:path [:x 0 0]
; :in [:x 0]
; :schema :string
; :value 1}
(get-in value (:in error))
;; => 1
(mu/get-in Schema (:path error))
;; => :string
Note! If you need error messages that serialize neatly to EDN/JSON, use malli.util/explain-data instead.
Humanized error messages
Explain results can be humanized with malli.error/humanize:
(require '[malli.error :as me])
(-> Address
(m/explain
{:id "Lillan"
:tags #{:artesan "coffee" :garden}
:address {:street "Ahlmanintie 29"
:zip 33100
:lonlat [61.4858322, nil]}})
(me/humanize))
;; => {:tags #{["should be a keyword"]}
;; :address {:city ["missing required key"]
;; :lonlat [nil ["should be a double"]]}}
Or if you already have a malli validation exception (e.g. in a catch form):
(require '[malli.error :as me])
(try
(m/assert Address {:not "an address"})
(catch Exception e
(-> e ex-data :data :explain me/humanize)))
Custom error messages
Error messages can be customized with :error/message and :error/fn properties.
If :error/message is of a predictable structure, it will automatically support custom [:not schema] failures for the following locales:
:en if message starts with should or should not then they will be swapped automatically. Otherwise, message is ignored.
;; e.g.,
(me/humanize
(m/explain
[:not
[:fn {:error/message {:en "should be a multiple of 3"}}
#(= 0 (mod % 3))]]
3))
;; => ["should not be a multiple of 3"]
The first argument to :error/fn is a map with keys:
:schema, the schema to explain
:value (optional), the value to explain
:negated (optional), a function returning the explanation of (m/explain [:not schema] value).
If provided, then we are explaining the failure of negating this schema via (m/explain [:not schema] value).
Note in this scenario, (m/validate schema value) is true.
If returning a string,
the resulting error message will be negated by the :error/fn caller in the same way as :error/message.
Returning (negated string) disables this behavior and string is used as the negated error message.
;; automatic negation
(me/humanize
(m/explain
[:not [:fn {:error/fn {:en (fn [_ _] "should not be a multiple of 3")}}
#(not= 0 (mod % 3))]]
1))
;; => ["should be a multiple of 3"]
;; manual negation
(me/humanize
(m/explain [:not [:fn {:error/fn {:en (fn [{:keys [negated]} _]
(if negated
(negated "should not avoid being a multiple of 3")
"should not be a multiple of 3"))}}
#(not= 0 (mod % 3))]] 1))
;; => ["should not avoid being a multiple of 3"]
Here are some basic examples of :error/message and :error/fn:
(-> [:map
[:id :int]
[:size [:enum {:error/message "should be: S|M|L"}
"S" "M" "L"]]
[:age [:fn {:error/fn (fn [{:keys [value]} _] (str value ", should be > 18"))}
(fn [x] (and (int? x) (> x 18)))]]]
(m/explain {:size "XL", :age 10})
(me/humanize
{:errors (-> me/default-errors
(assoc ::m/missing-key {:error/fn (fn [{:keys [in]} _] (str "missing key " (last in)))}))}))
;; => {:id ["missing key :id"]
;; :size ["should be: S|M|L"]
;; :age ["10, should be > 18"]}
Messages can be localized:
(-> [:map
[:id :int]
[:size [:enum {:error/message {:en "should be: S|M|L"
:fi "pitäisi olla: S|M|L"}}
"S" "M" "L"]]
[:age [:fn {:error/fn {:en (fn [{:keys [value]} _] (str value ", should be > 18"))
:fi (fn [{:keys [value]} _] (str value ", pitäisi olla > 18"))}}
(fn [x] (and (int? x) (> x 18)))]]]
(m/explain {:size "XL", :age 10})
(me/humanize
{:locale :fi
:errors (-> me/default-errors
(assoc-in ['int? :error/message :fi] "pitäisi olla numero")
(assoc ::m/missing-key {:error/fn {:en (fn [{:keys [in]} _] (str "missing key " (last in)))
:fi (fn [{:keys [in]} _] (str "puuttuu avain " (last in)))}}))}))
;; => {:id ["puuttuu avain :id"]
;; :size ["pitäisi olla: S|M|L"]
;; :age ["10, pitäisi olla > 18"]}
Top-level humanized map-errors are returned directly, without a wrapping map:
(-> [:and [:map
[:password :string]
[:password2 :string]]
[:fn {:error/message "passwords don't match"}
(fn [{:keys [password password2]}]
(= password password2))]]
(m/explain {:password "secret"
:password2 "faarao"})
(me/humanize))
;; => ["passwords don't match"]
Errors can be targeted using :error/path property:
(-> [:and [:map
[:password :string]
[:password2 :string]]
[:fn {:error/message "passwords don't match"
:error/path [:password2]}
(fn [{:keys [password password2]}]
(= password password2))]]
(m/explain {:password "secret"
:password2 "faarao"})
(me/humanize))
;; => {:password2 ["passwords don't match"]}
By default, only direct erroneous schema properties are used:
(-> [:map
[:foo {:error/message "entry-failure"} :int]] ;; here, :int fails, no error props
(m/explain {:foo "1"})
(me/humanize))
;; => {:foo ["should be an integer"]}
Looking up humanized errors from parent schemas with custom :resolve (BETA, subject to change):
(-> [:map
[:foo {:error/message "entry-failure"} :int]]
(m/explain {:foo "1"})
(me/humanize {:resolve me/-resolve-root-error}))
;; => {:foo ["entry-failure"]}
Spell checking
For closed schemas, key spelling can be checked with:
(require '[malli.util :as mu])
(-> [:map [:address [:map [:street :string]]]]
(mu/closed-schema)
(m/explain
{:name "Lie-mi"
:address {:streetz "Hämeenkatu 14"}})
(me/with-spell-checking)
(me/humanize))
;; => {:address {:streetz ["should be spelled :street"]}
;; :name ["disallowed key"]}
Custom errors
If you want even more control over error generation, you can use the
experimental :validate schema, which takes a validation function,
that should return a sequence of errors, or nil if the value was
valid.
The returned errors need :in and :value. If providing custom errors,
they also need :type, and if relying on existing schemas, they also
need :schema.
(require '[malli.experimental.validate :as mev])
(def UniqueNames
(m/schema [:and
; use normal schemas for basic structure validation
[:vector
[:map
[:name :string]
[:value :int]]]
; and a :validate for more fine-grainer errors
[:validate
(fn [entries]
(let [freqs (frequencies (map :name entries))]
(keep-indexed (fn [i entry]
(when (> (get freqs (:name entry)) 1)
{:in [i :name] ; data path, relative to schema
:type :duplicate-name
:value (:name entry)}))
entries)))]]
{:registry (merge (m/default-schemas) (mev/schemas))}))
(->> (m/explain UniqueNames [{:name "foo" :value 1}
{:name "bar" :value 2}
{:name "foo" :value 3}])
:errors
(map #(dissoc % :schema)))
;; => [{:in [0 :name]
;; :path [1]
;; :type :duplicate-name
;; :value "foo"}
;; {:in [2 :name]
;; :path [1]
;; :type :duplicate-name
;; :value "foo"}]
Custom errors can be humanized as well:
(-> UniqueNames
(m/explain [{:name "foo" :value 1}
{:name "bar" :value 2}
{:name "foo" :value 3}])
(me/humanize {:errors
{:duplicate-name
{:error/fn {:en (fn [{:keys [value]} _]
(str "duplicate name: " value))}}}}))
;; => [{:name ["duplicate name: foo"]} nil {:name ["duplicate name: foo"]}]
Errors can also be generated by the :errors entry from a m/explain call,
in which case me/humanize will use the existing messages.
Values in error
Just to get parts of the value that are in error:
(-> Address
(m/explain
{:id "Lillan"
:tags #{:artesan "coffee" :garden "ground"}
:address {:street "Ahlmanintie 29"
:zip 33100
:lonlat [61.4858322, "23.7832851,17"]}})
(me/error-value))
;; => {:tags #{"coffee" "ground"}
;; :address {:lonlat [nil "23.7832851,17"]}}
Masking irrelevant parts:
(-> Address
(m/explain
{:id "Lillan"
:tags #{:artesan "coffee" :garden "ground"}
:address {:street "Ahlmanintie 29"
:zip 33100
:lonlat [61.4858322, "23.7832851,17"]}})
(me/error-value {::me/mask-valid-values '...}))
;; => {:id ...
;; :tags #{"coffee" "ground" ...}
;; :address {:street ...
;; :zip ...
;; :lonlat [... "23.7832851,17"]}}
Pretty errors
There are two ways to get pretty errors:
Development mode
Start development mode:
((requiring-resolve 'malli.dev/start!))
Now, any exception thrown via malli.core/-fail! is being captured and pretty printed before being thrown. Pretty printing is extendable using virhe.
Pretty Coercion:
Custom exception (with default layout):
Pretty printing in being backed by malli.dev.virhe/-format multimethod using (-> exception (ex-data) :data) as the default dispatch key. As fallback, exception class - or exception subclass can be used, e.g. the following will handle all java.sql.SQLException and it’s parent exceptions:
(require '[malli.dev.virhe :as v])
(defmethod v/-format java.sql.SQLException [e _ printer]
{:title "Exception thrown"
:body [:group
(v/-block "SQL Exception" (v/-color :string (ex-message e) printer) printer) :break :break
(v/-block "More information:" (v/-link "https://cljdoc.org/d/metosin/malli/CURRENT" printer) printer)]})
pretty/explain
For pretty development-time error printing, try malli.dev.pretty/explain
(require '[malli.transform :as mt])
Two-way schema-driven value transformations with m/decode and m/encode using a Transformer instance.
Default Transformers include:
| name |
description |
mt/string-transformer |
transform between strings and EDN |
mt/json-transformer |
transform between JSON and EDN |
mt/strip-extra-keys-transformer |
drop extra keys from maps |
mt/default-value-transformer |
applies default values from schema properties |
mt/key-transformer |
transforms map keys |
mt/collection-transformer |
conversion between collections (e.g. set -> vector) |
NOTE: the included transformers are best-effort, i.e. they won’t throw on bad input, they will just pass the input value through unchanged. You should make sure your schema validation catches these non-transformed values. Custom transformers should follow the same idiom.
Simple usage:
(m/decode :int "42" mt/string-transformer)
;; => 42
(m/encode :int 42 mt/string-transformer)
;; => "42"
For performance, precompute the transformations with m/decoder and m/encoder:
(def decode (m/decoder :int mt/string-transformer))
(decode "42")
;; => 42
(def encode (m/encoder :int mt/string-transformer))
(encode 42)
;; => "42"
See also Value Transformation
Coercion
For both decoding + validating the results (throwing exception on error), there is m/coerce and m/coercer:
(m/coerce :int "42" mt/string-transformer)
;; => 42
((m/coercer :int mt/string-transformer) "42")
;; => 42
(m/coerce :int "invalid" mt/string-transformer)
; =throws=> :malli.core/invalid-input {:value "invalid", :schema :int, :explain {:schema :int, :value "invalid", :errors ({:path [], :in [], :schema :int, :value "invalid"})}}
Coercion can be applied without transformer, doing just validation:
(m/coerce :int 42)
; 42
(m/coerce :int "42")
; =throws=> :malli.core/invalid-input {:value "42", :schema :int, :explain {:schema :int, :value "42", :errors ({:path [], :in [], :schema :int, :value "42"})}}
Exception-free coercion with continuation-passing style:
(m/coerce :int "fail" nil (partial prn "success:") (partial prn "error:"))
;; =stdout=> "error:" {:value "fail", :schema :int, :explain ...}
Transformations are recursive:
(m/decode
Address
{:id "Lillan",
:tags ["coffee" "artesan" "garden"],
:address {:street "Ahlmanintie 29"
:city "Tampere"
:zip 33100
:lonlat [61.4858322 23.7854658]}}
mt/json-transformer)
;; => {:id "Lillan",
;; :tags #{:coffee :artesan :garden},
;; :address {:street "Ahlmanintie 29"
;; :city "Tampere"
;; :zip 33100
;; :lonlat [61.4858322 23.7854658]}}
Transform map keys:
(m/encode
Address
{:id "Lillan",
:tags ["coffee" "artesan" "garden"],
:address {:street "Ahlmanintie 29"
:city "Tampere"
:zip 33100
:lonlat [61.4858322 23.7854658]}}
(mt/key-transformer {:encode name}))
;; => {"id" "Lillan",
;; "tags" ["coffee" "artesan" "garden"],
;; "address" {"street" "Ahlmanintie 29"
;; "city" "Tampere"
;; "zip" 33100
;; "lonlat" [61.4858322 23.7854658]}}
Transforming homogenous :enum or :=s (supports automatic type detection of :keyword, :symbol, :int and :double):
(m/decode [:enum :kikka :kukka] "kukka" mt/string-transformer)
;; => :kukka
Transformers can be composed with mt/transformer:
(def strict-json-transformer
(mt/transformer
mt/strip-extra-keys-transformer
mt/json-transformer))
(m/decode
Address
{:id "Lillan",
:EVIL "LYN"
:tags ["coffee" "artesan" "garden"],
:address {:street "Ahlmanintie 29"
:DARK "ORKO"
:city "Tampere"
:zip 33100
:lonlat [61.4858322 23.7854658]}}
strict-json-transformer)
;; => {:id "Lillan",
;; :tags #{:coffee :artesan :garden},
;; :address {:street "Ahlmanintie 29"
;; :city "Tampere"
;; :zip 33100
;; :lonlat [61.4858322 23.7854658]}}
Schema properties can be used to override default transformations.
This uses the transformer :name (which is :string for mt/string-transformer) to match overrides to transformers.
(m/decode
[:string {:decode/string clojure.string/upper-case}]
"kerran" mt/string-transformer)
;; => "KERRAN"
This works too:
(m/decode
[:string {:decode {:string clojure.string/upper-case}}]
"kerran" mt/string-transformer)
;; => "KERRAN"
Decoders and encoders as interceptors (with :enter and :leave stages):
(m/decode
[:string {:decode/string {:enter clojure.string/upper-case}}]
"kerran" mt/string-transformer)
;; => "KERRAN"
(m/decode
[:string {:decode/string {:enter #(str "olipa_" %)
:leave #(str % "_avaruus")}}]
"kerran" mt/string-transformer)
;; => "olipa_kerran_avaruus"
To access Schema (and options) use :compile. Note the use of a custom transformer :name and property overrides for its implementation.
(m/decode
[:int {:math/multiplier 10
:decode/math {:compile (fn [schema _]
(let [multiplier (:math/multiplier (m/properties schema))]
(fn [x] (* x multiplier))))}}]
12
(mt/transformer {:name :math}))
;; => 120
Going crazy:
(m/decode
[:map
{:decode/math {:enter #(update % :x inc)
:leave #(update % :x (partial * 2))}}
[:x [:int {:decode/math {:enter (partial + 2)
:leave (partial * 3)}}]]]
{:x 1}
(mt/transformer {:name :math}))
;; => {:x 24}
:and accumulates the transformed value left-to-right.
(m/decode
[:and
[:string {:decode/string '{:enter #(str "1_" %), :leave #(str % "_2")}}]
[:string {:decode/string '{:enter #(str "3_" %), :leave #(str % "_4")}}]]
"kerran" mt/string-transformer)
;; => "3_1_kerran_2_4"
:or transforms using the first successful schema, left-to-right.
(m/decode
[:or
[:string {:decode/string '{:enter #(str "1_" %), :leave #(str % "_2")}}]
[:string {:decode/string '{:enter #(str "3_" %), :leave #(str % "_4")}}]]
"kerran" mt/string-transformer)
;; => "1_kerran_2"
(m/decode
[:or
:map
[:string {:decode/string '{:enter #(str "3_" %), :leave #(str % "_4")}}]]
"kerran" mt/string-transformer)
;; => "3_kerran_4"
Proxy schemas like :merge and :union transform as if m/derefed.
(m/decode
[:merge
[:map [:name [:string {:default "kikka"}]] ]
[:map [:description {:optional true} [:string {:default "kikka"}]]]]
{}
{:registry (merge (mu/schemas) (m/default-schemas))}
(mt/default-value-transformer {::mt/add-optional-keys true}))
;; => {:name "kikka"
; :description "kikka"}
To and from JSON
The m/encode and m/decode functions work on clojure data. To go
from clojure data to JSON, you need a JSON library like
jsonista. Additionally, since
m/decode doesn’t check the schema, you need to run m/validate (or
m/explain) if you want to make sure your data conforms to your
schema.
To JSON:
(require 'jsonista.core)
(def Tags
(m/schema [:map
{:closed true}
[:tags [:set :keyword]]]))
(jsonista.core/write-value-as-string
(m/encode Tags
{:tags #{:bar :quux}}
mt/json-transformer))
;; => "{\"tags\":[\"bar\",\"quux\"]}"
From JSON without validation:
(m/decode Tags
(jsonista.core/read-value "{\"tags\":[\"bar\",[\"quux\"]]}"
jsonista.core/keyword-keys-object-mapper)
mt/json-transformer)
;; => {:tags #{:bar ["quux"]}}
From JSON with validation:
(m/explain Tags
(m/decode Tags
(jsonista.core/read-value "{\"tags\":[\"bar\",[\"quux\"]]}"
jsonista.core/keyword-keys-object-mapper)
mt/json-transformer))
;; => {:schema [:map {:closed true} [:tags [:set :keyword]]],
;; :value {:tags #{:bar ["quux"]}},
;; :errors ({:path [:tags 0], :in [:tags ["quux"]], :schema :keyword, :value ["quux"]})}
(m/validate Tags
(m/decode Tags
(jsonista.core/read-value "{\"tags\":[\"bar\",\"quux\"]}" ; <- note! no error
jsonista.core/keyword-keys-object-mapper)
mt/json-transformer))
; => true
For performance, it’s best to prebuild the validator, decoder and explainer:
(def validate-Tags (m/validator Tags))
(def decode-Tags (m/decoder Tags mt/json-transformer))
(-> (jsonista.core/read-value "{\"tags\":[\"bar\",\"quux\"]}"
jsonista.core/keyword-keys-object-mapper)
decode-Tags
validate-Tags)
;; => true
Default values
Applying default values:
(m/decode [:and {:default 42} :int] nil mt/default-value-transformer)
;; => 42
With custom key and type defaults:
(m/decode
[:map
[:user [:map
[:name :string]
[:description {:ui/default "-"} :string]]]]
nil
(mt/default-value-transformer
{:key :ui/default
:defaults {:map (constantly {})
:string (constantly "")}}))
;; => {:user {:name "", :description "-"}}
With custom function:
(m/decode
[:map
[:os [:string {:property "os.name"}]]
[:timezone [:string {:property "user.timezone"}]]]
{}
(mt/default-value-transformer
{:key :property
:default-fn (fn [_ x] (System/getProperty x))}))
; => {:os "Mac OS X", :timezone "Europe/Helsinki"}
With ad-hoc custom function:
(def the-default-atom (atom 42))
(m/decode
[:map
[:a [:int {:default/fn #(deref the-default-atom)}]] ; property of schema
[:b {:default/fn #(deref the-default-atom)} :int]] ; property of map entry
{}
(mt/default-value-transformer))
;; => {:a 42 :b 42}
Optional Keys are not added by default:
(m/decode
[:map
[:name [:string {:default "kikka"}]]
[:description {:optional true} [:string {:default "kikka"}]]]
{}
(mt/default-value-transformer))
;; => {:name "kikka"}
Adding optional keys too via ::mt/add-optional-keys option:
(m/decode
[:map
[:name [:string {:default "kikka"}]]
[:description {:optional true} [:string {:default "kikka"}]]]
{}
(mt/default-value-transformer {::mt/add-optional-keys true}))
;; => {:name "kikka", :description "kikka"}
For nested maps, the intermediate levels need a :default {} or they
will not get populated:
(m/decode
[:map
[:ports [:map
[:http {:default 80} :int]
[:ftp :int]]]]
{}
mt/default-value-transformer)
;; => {}
(m/decode
[:map
[:ports [:map {:default {}}
[:http {:default 80} :int]
[:ftp :int]]]]
{}
mt/default-value-transformer)
;; => {:ports {:http 80}}
A workaround for this is to use :defaults {:map (constantly {})}:
(m/decode
[:map
[:ports [:map
[:http {:default 80} :int]
[:ftp :int]]]]
{}
(mt/default-value-transformer {:defaults {:map (constantly {})}}))
;; => {:ports {:http 80}}
Single sweep of defaults & string encoding:
(m/encode
[:map
[:a [:int {:default 1}]]
[:b [:vector {:default [1 2 3]} :int]]
[:c [:map
[:x [:int {:default 42}]]
[:y :int]]]
[:e :int]]
nil
(mt/transformer
(mt/default-value-transformer {:defaults {:map (constantly {})}})
mt/string-transformer))
;; => {:a "1"
;; :b ["1" "2" "3"]
;; :c {:x "42"}}
Programming with schemas
(require '[malli.util :as mu])
Updating Schema properties:
(mu/update-properties [:vector :int] assoc :min 1)
;; => [:vector {:min 1} :int]
Lifted clojure.core function to work with schemas: select-keys, dissoc, get, assoc, update, get-in, assoc-in, update-in
(mu/get-in Address [:address :lonlat])
; => [:tuple :double :double]
(mu/update-in Address [:address] mu/assoc :country [:enum "fi" "po"])
;[:map
; [:id :string]
; [:tags [:set :keyword]]
; [:address
; [:map [:street :string]
; [:city :string]
; [:zip :int]
; [:lonlat [:tuple :double :double]]
; [:country [:enum "fi" "po"]]]]]
(-> Address
(mu/dissoc :address)
(mu/update-properties assoc :title "Address"))
;[:map {:title "Address"}
; [:id :string]
; [:tags [:set :keyword]]]
Making keys optional or required:
(mu/optional-keys [:map [:x :int] [:y :int]])
;[:map
; [:x {:optional true} :int]
; [:y {:optional true} :int]]
(mu/optional-keys [:map [:x :int] [:y :int]]
[:x])
;[:map
; [:x {:optional true} :int]
; [:y :int]]
(mu/required-keys [:map [:x {:optional true} :int] [:y {:optional true} :int]])
;[:map
; [:x :int]
; [:y :int]]
(mu/required-keys [:map [:x {:optional true} :int] [:y {:optional true} :int]]
[:x])
;[:map
; [:x :int]
; [:y {:optional true} :int]]
Closing and opening all :map schemas recursively:
(def abcd
[:map {:title "abcd"}
[:a :int]
[:b {:optional true} :int]
[:c [:map
[:d :int]]]])
(mu/closed-schema abcd)
;[:map {:title "abcd", :closed true}
; [:a :int]
; [:b {:optional true} :int]
; [:c [:map {:closed true}
; [:d :int]]]]
(-> abcd
mu/closed-schema
mu/open-schema)
;[:map {:title "abcd"}
; [:a :int]
; [:b {:optional true} :int]
; [:c [:map
; [:d :int]]]]
Merging Schemas (last value wins):
(mu/merge
[:map
[:name :string]
[:description :string]
[:address
[:map
[:street :string]
[:country [:enum "finland" "poland"]]]]]
[:map
[:description {:optional true} :string]
[:address
[:map
[:country :string]]]])
;[:map
; [:name :string]
; [:description {:optional true} :string]
; [:address [:map
; [:street :string]
; [:country :string]]]]
With :and, first child is used in merge:
(mu/merge
[:and {:type "entity"}
[:map {:title "user"}
[:name :string]]
map?]
[:map {:description "aged"} [:age :int]])
;[:and {:type "entity"}
; [:map {:title "user", :description "aged"}
; [:name :string]
; [:age :int]]
; map?]
Schema unions (merged values of both schemas are valid for union schema):
(mu/union
[:map
[:name :string]
[:description :string]
[:address
[:map
[:street :string]
[:country [:enum "finland" "poland"]]]]]
[:map
[:description {:optional true} :string]
[:address
[:map
[:country :string]]]])
;[:map
; [:name :string]
; [:description {:optional true} :string]
; [:address [:map
; [:street :string]
; [:country [:or [:enum "finland" "poland"] :string]]]]]
Adding generated example values to Schemas:
(m/walk
[:map
[:name :string]
[:description :string]
[:address
[:map
[:street :string]
[:country [:enum "finland" "poland"]]]]]
(m/schema-walker
(fn [schema]
(mu/update-properties schema assoc :examples (mg/sample schema {:size 2, :seed 20})))))
;[:map
; {:examples ({:name "", :description "", :address {:street "", :country "poland"}}
; {:name "W", :description "x", :address {:street "8", :country "finland"}})}
; [:name [:string {:examples ("" "")}]]
; [:description [:string {:examples ("" "")}]]
; [:address
; [:map
; {:examples ({:street "", :country "finland"} {:street "W", :country "poland"})}
; [:street [:string {:examples ("" "")}]]
; [:country [:enum {:examples ("finland" "poland")} "finland" "poland"]]]]]
Finding first value (prewalk):
(mu/find-first
[:map
[:x :int]
[:y [:vector [:tuple
[:or [:and {:salaisuus "turvassa"} :boolean] :int]
[:schema {:salaisuus "vaarassa"} false?]]]]
[:z [:string {:salaisuus "piilossa"}]]]
(fn [schema _ _]
(-> schema m/properties :salaisuus)))
;; => "turvassa"
Finding all subschemas with paths, retaining order:
(def Schema
(m/schema
[:maybe
[:map
[:id :string]
[:tags [:set :keyword]]
[:address
[:and
[:map
[:street {:optional true} :string]
[:lonlat {:optional true} [:tuple :double :double]]]
[:fn (fn [{:keys [street lonlat]}] (or street lonlat))]]]]]))
(mu/subschemas Schema)
;[{:path [], :in [], :schema [:maybe
; [:map
; [:id :string]
; [:tags [:set :keyword]]
; [:address
; [:and
; [:map
; [:street {:optional true} :string]
; [:lonlat {:optional true} [:tuple :double :double]]]
; [:fn (fn [{:keys [street lonlat]}] (or street lonlat))]]]]]}
; {:path [0], :in [], :schema [:map
; [:id :string]
; [:tags [:set :keyword]]
; [:address
; [:and
; [:map
; [:street {:optional true} :string]
; [:lonlat {:optional true} [:tuple :double :double]]]
; [:fn (fn [{:keys [street lonlat]}] (or street lonlat))]]]]}
; {:path [0 :id], :in [:id], :schema :string}
; {:path [0 :tags], :in [:tags], :schema [:set :keyword]}
; {:path [0 :tags :malli.core/in], :in [:tags :malli.core/in], :schema :keyword}
; {:path [0 :address], :in [:address], :schema [:and
; [:map
; [:street {:optional true} :string]
; [:lonlat {:optional true} [:tuple :double :double]]]
; [:fn (fn [{:keys [street lonlat]}] (or street lonlat))]]}
; {:path [0 :address 0], :in [:address], :schema [:map
; [:street {:optional true} :string]
; [:lonlat {:optional true} [:tuple :double :double]]]}
; {:path [0 :address 0 :street], :in [:address :street], :schema :string}
; {:path [0 :address 0 :lonlat], :in [:address :lonlat], :schema [:tuple :double :double]}
; {:path [0 :address 0 :lonlat 0], :in [:address :lonlat 0], :schema :double}
; {:path [0 :address 0 :lonlat 1], :in [:address :lonlat 1], :schema :double}
; {:path [0 :address 1], :in [:address], :schema [:fn (fn [{:keys [street lonlat]}] (or street lonlat))]}]
Collecting unique value paths and their schema paths:
(->> Schema
(mu/subschemas)
(mu/distinct-by :id)
(mapv (juxt :in :path)))
;[[[] []]
; [[] [0]]
; [[:id] [0 :id]]
; [[:tags] [0 :tags]]
; [[:tags :malli.core/in] [0 :tags :malli.core/in]]
; [[:address] [0 :address]]
; [[:address] [0 :address 0]]
; [[:address :street] [0 :address 0 :street]]
; [[:address :lonlat] [0 :address 0 :lonlat]]
; [[:address :lonlat 0] [0 :address 0 :lonlat 0]]
; [[:address :lonlat 1] [0 :address 0 :lonlat 1]]
; [[:address] [0 :address 1]]]
Schema paths can be converted into value paths:
(mu/get-in Schema [0 :address 0 :lonlat])
; [:tuple :double :double]
(mu/path->in Schema [0 :address 0 :lonlat])
;; => [:address :lonlat]
and back, returning all paths:
(mu/in->paths Schema [:address :lonlat])
;; => [[0 :address 0 :lonlat]]
There are also declarative versions of schema transforming utilities in malli.util/schemas. These include :merge, :union and :select-keys:
(def registry (merge (m/default-schemas) (mu/schemas)))
(def Merged
(m/schema
[:merge
[:map [:x :string]]
[:map [:y :int]]]
{:registry registry}))
Merged
;[:merge
; [:map [:x :string]]
; [:map [:y :int]]]
(m/deref Merged)
;[:map
; [:x :string]
; [:y :int]]
(m/validate Merged {:x "kikka", :y 6})
;; => true
:union is similar to :or, except :union combines map schemas in different disjuncts with :or.
For example, UnionMaps is equivalent to [:map [:x [:or :int :string]] [:y [:or :int :string]]].
(def OrMaps
(m/schema
[:or
[:map [:x :int] [:y :string]]
[:map [:x :string] [:y :int]]]
{:registry registry}))
(def UnionMaps
(m/schema
[:union
[:map [:x :int] [:y :string]]
[:map [:x :string] [:y :int]]]
{:registry registry}))
(m/validate OrMaps {:x "kikka" :y "kikka"})
;; => false
(m/validate UnionMaps {:x "kikka" :y "kikka"})
;; => true
:merge and :union differ on schemas with common keys. :merge chooses the right-most
schema of common keys, and :union combines them with :or.
For example, MergedCommon is equivalent to [:map [:x :int]], and UnionCommon
is equivalent to [:map [:x [:or :string :int]]].
(def MergedCommon
(m/schema
[:merge
[:map [:x :string]]
[:map [:x :int]]]
{:registry registry}))
(def UnionCommon
(m/schema
[:union
[:map [:x :string]]
[:map [:x :int]]]
{:registry registry}))
(m/validate MergedCommon {:x "kikka"})
;; => false
(m/validate MergedCommon {:x 1})
;; => true
(m/validate UnionCommon {:x "kikka"})
;; => true
(m/validate UnionCommon {:x 1})
;; => true
Distributive schemas
:merge also distributes over :multi in a similar way to how multiplication
distributes over addition in arithmetic. There are two transformation rules, applied in the following order:
;; right-distributive
[:merge [:multi M1 M2 ...] M3]
=>
[:multi [:merge M1 M3] [:merge M2 M3] ...]
;; left-distributive
[:merge M1 [:multi M2 M3 ...]]
=>
[:multi [:merge M1 M2] [:merge M1 M3] ...]
For :merge with more than two arguments, the rules are applied iteratively left-to-right
as if the following transformation was applied:
[:merge M1 M2 M3 M4 ...]
=>
[:merge
[:merge
[:merge M1 M2]
M3]
M4]
...
The distributive property of :multi is useful combined with :merge
if you want all clauses of a :multi to share extra entries.
Here are concrete examples of applying the rules:
;; left-distributive
(m/deref
[:merge
[:map [:x :int]]
[:multi {:dispatch :y}
[1 [:map [:y [:= 1]]]]
[2 [:map [:y [:= 2]]]]]]
{:registry registry})
; [:multi {:dispatch :y}
; [1 [:map [:x :int] [:y [:= 1]]]]
; [2 [:map [:x :int] [:y [:= 2]]]]]
;; right-distributive
(m/deref
[:merge
[:multi {:dispatch :y}
[1 [:map [:y [:= 1]]]]
[2 [:map [:y [:= 2]]]]]
[:map [:x :int]]]
{:registry registry})
; [:multi {:dispatch :y}
; [1 [:map [:y [:= 1]] [:x :int]]]
; [2 [:map [:y [:= 2]] [:x :int]]]]
It is not recommended to use local registries in schemas that are transformed.
Also be aware that merging non-maps via the distributive property inherits
the same semantics as :merge, which is based on meta-merge.
Persisting schemas
Writing and Reading schemas as EDN, no eval needed.
Following example requires SCI or
cherry as external dependency because
it includes a (quoted) function definition. See Serializable
functions.
(require '[malli.edn :as edn])
(-> [:and
[:map
[:x :int]
[:y :int]]
[:fn '(fn [{:keys [x y]}] (> x y))]]
(edn/write-string)
(doto prn) ; => "[:and [:map [:x :int] [:y :int]] [:fn (fn [{:keys [x y]}] (> x y))]]"
(edn/read-string)
(doto (-> (m/validate {:x 0, :y 1}) prn)) ; => false
(doto (-> (m/validate {:x 2, :y 1}) prn))) ; => true
;[:and
; [:map
; [:x :int]
; [:y :int]]
; [:fn (fn [{:keys [x y]}] (> x y))]]
Multi schemas
Closed dispatch with :multi schema and :dispatch property:
(m/validate
[:multi {:dispatch :type}
[:sized [:map [:type :keyword] [:size :int]]]
[:human [:map [:type :keyword] [:name :string] [:address [:map [:country :keyword]]]]]]
{:type :sized, :size 10})
;; => true
Default branch with ::m/default:
(def valid?
(m/validator
[:multi {:dispatch :type}
["object" [:map-of :keyword :string]]
[::m/default :string]]))
(valid? {:type "object", :key "1", :value "100"})
;; => true
(valid? "SUCCESS!")
;; => true
(valid? :failure)
;; => false
Any function can be used for :dispatch:
(m/validate
[:multi {:dispatch first}
[:sized [:tuple :keyword [:map [:size :int]]]]
[:human [:tuple :keyword [:map [:name :string] [:address [:map [:country :keyword]]]]]]]
[:human {:name "seppo", :address {:country :sweden}}])
;; => true
:dispatch values should be decoded before actual values:
(m/decode
[:multi {:dispatch :type
:decode/string #(update % :type keyword)}
[:sized [:map [:type [:= :sized]] [:size :int]]]
[:human [:map [:type [:= :human]] [:name :string] [:address [:map [:country :keyword]]]]]]
{:type "human"
:name "Tiina"
:age "98"
:address {:country "finland"
:street "this is an extra key"}}
(mt/transformer mt/strip-extra-keys-transformer mt/string-transformer))
;; => {:type :human
;; :name "Tiina"
;; :address {:country :finland}}
Recursive schemas
To create a recursive schema, introduce a local registry and wrap all recursive positions in the registry with :ref. Now you may reference the recursive schemas in the body of the schema.
For example, here is a recursive schema using :schema for singly-linked lists of positive integers:
(m/validate
[:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
[:ref ::cons]]
[16 [64 [26 [1 [13 nil]]]]])
;; => true
Without the :ref keyword, malli eagerly expands the schema until a stack overflow error is thrown:
(m/validate
[:schema {:registry {::cons [:maybe [:tuple pos-int? ::cons]]}}
::cons]
[16 [64 [26 [1 [13 nil]]]]])
; StackOverflowError
Technically, you only need the :ref in recursive positions. However, it is best practice to :ref all references
to recursive variables for better-behaving generators:
;; Note:
[:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
::cons]
;; produces the same generator as the "unfolded"
[:maybe [:tuple pos-int? [:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}} ::cons]]]
;; while
[:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
[:ref ::cons]]
;; has a direct correspondance to the following generator:
(gen/recursive-gen
(fn [rec] (gen/one-of [(gen/return nil) (gen/tuple rec)]))
(gen/return nil))
Mutual recursion works too. Thanks to the :schema construct, many schemas could be defined in the local registry, the top-level one being promoted by the :schema second parameter:
(m/validate
[:schema {:registry {::ping [:maybe [:tuple [:= "ping"] [:ref ::pong]]]
::pong [:maybe [:tuple [:= "pong"] [:ref ::ping]]]}}
::ping]
["ping" ["pong" ["ping" ["pong" ["ping" nil]]]]])
;; => true
Nested registries, the last definition wins:
(m/validate
[:schema {:registry {::ping [:maybe [:tuple [:= "ping"] [:ref ::pong]]]
::pong any?}} ;; effectively unreachable
[:schema {:registry {::pong [:maybe [:tuple [:= "pong"] [:ref ::ping]]]}}
::ping]]
["ping" ["pong" ["ping" ["pong" ["ping" nil]]]]])
;; => true
Value generation
Schemas can be used to generate values:
(require '[malli.generator :as mg])
;; random
(mg/generate :keyword)
; => :?
;; using seed
(mg/generate [:enum "a" "b" "c"] {:seed 42})
;; => "a"
;; using seed and size
(mg/generate pos-int? {:seed 10, :size 100})
;; => 55740
;; regexs work too (only clj and if [com.gfredericks/test.chuck "0.2.10"+] available)
(mg/generate
[:re #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"]
{:seed 42, :size 10})
; => "[email protected]"
;; :gen/return (note, not validated)
(mg/generate
[:and {:gen/return 42} :int])
; => 42
;; :gen/elements (note, are not validated)
(mg/generate
[:and {:gen/elements ["kikka" "kukka" "kakka"]} :string]
{:seed 10})
; => "kikka"
;; :gen/fmap
(mg/generate
[:and {:gen/fmap (partial str "kikka_")} :string]
{:seed 10, :size 10})
;; => "kikka_WT3K0yax2"
;; portable :gen/fmap (requires `org.babashka/sci` dependency to work)
(mg/generate
[:and {:gen/fmap '(partial str "kikka_")} :string]
{:seed 10, :size 10})
;; => "kikka_nWT3K0ya7"
;; :gen/schema
(mg/generate
[:any {:gen/schema [:int {:min 10, :max 20}]}]
{:seed 10})
; => 19
;; :gen/min & :gen/max for numbers and collections
(mg/generate
[:vector {:gen/min 4, :gen/max 4} :int]
{:seed 1})
; => [-8522515 -1433 -1 1]
;; :gen/min & gen/max works for :+ and :* as well
(mg/generate
[:+ {:gen/min 2 :gen/max 10} :int]
{:seed 10})
; => [-109024846 -2 25432]
;; When composing sequence schemas, the directives effect the definition they are
;; associated with, such that:
(mg/generate
[:* {:gen/min 2 :gen/max 3} ; 2 - 3 repetitions of
[:cat
[:+ {:gen/min 2 :gen/max 3} :int] ; 2 - 3 repetitions of int
[:* {:gen/min 1 :gen/max 2} :string]]] ; followed by 1-2 repetitions of string
{:seed 10})
; => (-812 1283 "Q9beps1Yn3c3VP9" "4XHdn1mgudSlNpVyxOrQIiR5pd5ocs" 114 -14284153 "8SSR9033czAO05")
;; :gen/infinite? & :gen/NaN? for :double
(mg/generate
[:double {:gen/infinite? true, :gen/NaN? true}]
{:seed 1})
; => ##Inf
(require '[clojure.test.check.generators :as gen])
;; gen/gen (note, not serializable)
(mg/generate
[:sequential {:gen/gen (gen/list gen/neg-int)} :int]
{:size 42, :seed 42})
; => (-37 -13 -13 -24 -20 -11 -34 -40 -22 0 -10)
Generated values are valid:
(mg/generate Address {:seed 123, :size 4})
;{:id "H7",
; :tags #{:v?.w.t6!.QJYk-/-?s*4
; :_7U
; :QdG/Xi8J
; :*Q-.p*8*/n-J9u}
; :address {:street "V9s"
; :city ""
; :zip 3
; :lonlat [-2.75 -0.625]}}
(m/validate Address (mg/generate Address))
;; => true
Sampling values:
;; sampling
(mg/sample [:and :
> README 内容过长,已截取前 64 KB。完整内容请前往 GitHub 仓库查看。
评论