Eclipse Public License 2.0

项目介绍

metosin/malli 是一个托管在 GitHub 上的开源项目,主要使用 Clojure 编写,采用 Eclipse Public License 2.0 许可证,目前获得 1.8k 个 Star、242 次 Fork。

仓库全名
metosin/malli
开源许可证
Eclipse Public License 2.0
Star 数
1.8k
Fork 数
242
未关闭 Issue
121

README 摘要

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…

完整内容见 GitHub 原始 README 或本页 。

常见问题

malli 用什么语言开发?

主要使用 Clojure 编写,采用 Eclipse Public License 2.0 许可证,目前有 1.8k 个 Star。

malli 可以免费商用吗?

仓库标注的许可证为 Eclipse Public License 2.0,商用前请先阅读仓库 LICENSE 文件确认授权范围。

如何获取 malli 的源码?

可以直接克隆仓库:git clone https://github.com/metosin/malli.git,默认分支为 master;安装与运行步骤见仓库 README。

malli 最近还在维护吗?

仓库最近一次更新为 4 天前,当前有 121 个未关闭 Issue;Star 数为 1.8k,可作为社区活跃度的参考。

README

malli

Build Status cljdoc badge Clojars Project Slack bb compatible

Data-driven Schemas for Clojure/Script and babashka.

Metosin Open Source Status: Active. Stability: well matured alpha.

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

Clojars Project

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:

  1. :seqable parses its elements but :every does not and returns the identical input, and
  2. 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

Value transformation

(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 ...}

Advanced Transformations

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]]

Declarative schema transformation

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 仓库查看。

评论

暂无评论