Sombra
Sign in
6 articlesShared 3 months agoLive

Malli Reference

Malli schema validation library reference, patterns, and snippets for Clojure/ClojureScript development

Distilled Context

Malli Reference

metosin/malli 0.20.0 · Clojure 1.11+ / ClojureScript 1.11.51+ High-performance data-driven schema library. Schemas are data — vectors, maps, keywords.

Setup

;; deps.edn
{:deps {metosin/malli {:mvn/version "0.20.0"}}}

;; Core requires
(require '[malli.core :as m])
(require '[malli.error :as me])
(require '[malli.transform :as mt])
(require '[malli.util :as mu])
(require '[malli.generator :as mg])

1. Schema Syntax

Schemas use hiccup-style vectors: type, [type & children], or [type properties & children].

;; Primitives
:string :int :double :boolean :keyword :symbol :uuid :any :nil

;; With constraints
[:string {:min 1, :max 100}]
[:int {:min 0, :max 999}]

;; Predicates (no properties support)
string? int? pos-int? nat-int? number? boolean? keyword? uuid? nil?

;; Enums and constants
[:enum "pending" "active" "cancelled"]
[:= 42]

;; Maybe (value or nil)
[:maybe :string]

;; Regex
[:re #"^\d{4}-\d{2}-\d{2}$"]

2. Schema Taxonomy (Mental Model)

Schemas fall into five categories:

  • Base values — primitives like :int, :string, :boolean, predicate schemas like int?
  • Boxes — wrappers/references: :maybe, :ref, :schema, :sequential, :vector, :set
  • Comparators:=, :not=, :>, :>=, :<, :<=
  • Conjunctions:and, :map, :cat/:catn, :tuple (all children must match)
  • Disjunctions:or/:orn, :multi, :alt/:altn, :enum (one child must match; these backtrack)

Sequence pattern schemas (:cat, :alt, :*, :+, :?, :repeat) are orthogonal — they describe sequential structure.

3. Map Schemas

;; Open by default (extra keys allowed)
(def Product
  [:map
   [:id :uuid]
   [:name [:string {:min 1}]]
   [:price :double]
   [:category [:enum "food" "drink" "service"]]
   [:description {:optional true} :string]])

;; Closed map (no extra keys)
[:map {:closed true}
 [:x :int]
 [:y :int]]

;; Qualified keys with local registry
[:map {:registry {::id :uuid, ::status [:enum "active" "archived"]}}
 ::id
 [:name :string]
 [::status {:optional true}]]

;; Homogeneous maps
[:map-of :string :int]
[:map-of :keyword Product]

;; Default schema for extra keys
[:map
 [:x :int]
 [:y :int]
 [:malli.core/default [:map-of :keyword :any]]]

4. Collection Schemas

;; Sequential (any seq)
[:sequential :int]

;; Vector (strict)
[:vector :int]

;; Set
[:set :keyword]

;; Tuple (fixed-length heterogeneous vector)
[:tuple :double :double]
[:tuple :string :int :boolean]

;; Seqable (any seqable)
[:seqable :int]

;; Every (sampled validation for large uncounted collections)
[:every :int]

:seqable vs :every: :every is bounded/sampled — it won't traverse the entire collection. This means it can miss invalid values at the tail:

(m/validate [:every :int] (conj (vec (range 1000)) nil))  ;; => false (vector, all checked)
(m/validate [:every :int] (concat (range 1000) [nil]))     ;; => true (lazy, sampled!)

5. Sequence Schemas (Regex-like)

;; Concatenation
[:cat :string :int]
[:catn [:name :string] [:age :int]]

;; Alternatives
[:alt :keyword :string]
[:altn [:kw :keyword] [:s :string]]

;; Repetition
[:? :int]             ;; zero or one
[:* :int]             ;; zero or more
[:+ :int]             ;; one or more
[:repeat {:min 2, :max 4} :int]

;; Named subsequences for better error paths
[:* [:catn [:prop :string] [:val [:altn [:s :string] [:b :boolean]]]]]

6. Combinators

;; And (all must match)
[:and :int pos-int? [:< 100]]

;; Or (any must match)
[:or :string :int]

;; Not
[:not :int]

;; Fn predicate
[:fn {:error/message "must be positive"}
 (fn [x] (pos? x))]

;; Fn on maps (cross-field validation)
[:and
 [:map
  [:password :string]
  [:password2 :string]]
 [:fn {:error/message "passwords don't match"
       :error/path [:password2]}
  (fn [{:keys [password password2]}]
    (= password password2))]]

7. Multi Schemas (Polymorphic dispatch)

;; Dispatch on :type key
(def OrderItem
  [:multi {:dispatch :type}
   [:food [:map [:type [:= :food]] [:name :string] [:allergens [:set :keyword]]]]
   [:drink [:map [:type [:= :drink]] [:name :string] [:volume :double]]]
   [:service [:map [:type [:= :service]] [:name :string] [:duration :int]]]])

;; Dispatch with function
[:multi {:dispatch (fn [x] (if (:premium x) :premium :standard))}
 [:premium [:map [:premium :boolean] [:vip-code :string]]]
 [:standard [:map [:premium :boolean]]]]

Composing Multi Variants with :merge

Pull common fields into a base schema and merge per-variant to avoid duplication:

(def BaseEvent
  [:map
   [:T {:min 0} :int]
   [:E {:min 0} :int]])

(def EventSchema
  [:multi {:dispatch :e}
   ["GRID_UPDATE"
    [:merge
     BaseEvent
     [:map
      [:e [:= "GRID_UPDATE"]]
      [:payload [:map [:symbol :string] [:status [:enum "NEW" "WORKING" "CANCELLED"]]]]]]]
   ["ORDER_REJECT"
    [:merge
     BaseEvent
     [:map
      [:e [:= "ORDER_REJECT"]]
      [:reason :string]]]]])

;; Requires mu/schemas in registry
(def registry (merge (mu/schemas) (m/default-schemas)))
(m/validate EventSchema event {:registry registry})

8. Recursive Schemas

;; Using :ref and local registry
(def Category
  [:schema {:registry {"category" [:map
                                   [:id :uuid]
                                   [:name :string]
                                   [:subcategories [:vector [:ref "category"]]]]}}
   "category"])

;; Using Var references
(def UserId :string)
(def Address [:map [:street :string] [:country [:enum "NL" "BE"]]])
(def User [:map [:id #'UserId] [:address #'Address]])

9. Validation

;; Basic
(m/validate :int 1)           ;; => true
(m/validate :int "1")         ;; => false

;; Precompiled validator (FAST — use in hot paths)
(def valid-product? (m/validator Product))
(valid-product? {:id (random-uuid) :name "Widget" :price 2.50 :category "food"})
;; => true

;; Explain (returns nil on success, error map on failure)
(m/explain Product {:id "bad" :name "" :price "nope"})
;; => {:schema ..., :value ..., :errors [...]}

;; Humanized errors
(-> Product
    (m/explain {:id "bad" :name "" :price "nope"})
    (me/humanize))
;; => {:id ["should be a uuid"], :name ["should be at least 1 characters"],
;;     :price ["should be a double"], :category ["missing required key"]}

10. Parsing (Tagged Unions)

Schemas with alternation support parsing into tagged results:

;; orn → tagged map
(m/parse [:orn [:kw :keyword] [:s :string]] :foo)
;; => {:key :kw, :value :foo}

;; multi → tagged by dispatch key
(m/parse EventSchema event {:registry registry})
;; => {:key "GRID_UPDATE", :value {...}}

;; catn → named sequence parts
(m/parse
  [:catn [:nums [:* :int]] [:flag :boolean] [:opts [:* :string]]]
  [1 2 3 true "a" "b"])
;; => {:values {:nums [1 2 3], :flag true, :opts ["a" "b"]}}

;; unparse: reverse of parse
(m/unparse [:orn [:kw :keyword] [:s :string]] {:key :kw, :value :foo})
;; => :foo

11. Value Transformation (Coercion)

Built-in Transformers

TransformerPurpose
mt/string-transformerString ↔ EDN (form params, query strings)
mt/json-transformerJSON ↔ EDN (keywords from strings, sets from vectors)
mt/strip-extra-keys-transformerDrop extra keys from maps
mt/default-value-transformerApply :default from schema properties
mt/key-transformerTransform map keys
mt/collection-transformerCollection type conversion

Decode / Encode

;; String → int
(m/decode :int "42" mt/string-transformer)  ;; => 42

;; JSON → EDN (keywords, sets)
(m/decode [:set :keyword] ["coffee" "tea"] mt/json-transformer)
;; => #{:coffee :tea}

;; Precompiled for performance
(def decode-product (m/decoder Product mt/json-transformer))
(def encode-product (m/encoder Product mt/json-transformer))

;; Compose transformers
(def strict-json
  (mt/transformer
    mt/strip-extra-keys-transformer
    mt/json-transformer))

Coercion (Decode + Validate in one step)

;; Throws on invalid input
(m/coerce :int "42" mt/string-transformer)  ;; => 42
(m/coerce :int "bad" mt/string-transformer) ;; throws :malli.core/invalid-input

;; Precompiled coercer
(def coerce-product (m/coercer Product mt/json-transformer))

;; Exception-free coercion (CPS)
(m/coerce :int "bad" nil
  (fn [v] (println "ok:" v))
  (fn [e] (println "err:" (:value e))))
;; prints: err: bad

Default Values

;; Simple defaults
(m/decode
  [:map
   [:status [:string {:default "draft"}]]
   [:quantity [:int {:default 1}]]]
  {}
  mt/default-value-transformer)
;; => {:status "draft", :quantity 1}

;; Include optional keys
(m/decode
  [:map
   [:name [:string {:default "Unknown"}]]
   [:note {:optional true} [:string {:default ""}]]]
  {}
  (mt/default-value-transformer {::mt/add-optional-keys true}))
;; => {:name "Unknown", :note ""}

;; Combine defaults + string decoding in one pass
(m/encode
  [:map {:default {}}
   [:count [:int {:default 0}]]
   [:tags [:vector {:default []} :string]]]
  nil
  (mt/transformer
    mt/default-value-transformer
    mt/string-transformer))
;; => {:count "0", :tags []}

Custom Schema-Driven Transforms

;; Override per-schema decode/encode
[:string {:decode/string clojure.string/upper-case}]

;; With enter/leave stages
[:string {:decode/string {:enter clojure.string/trim
                          :leave clojure.string/lower-case}}]

;; Access schema properties in :compile
[:int {:multiplier 100
       :decode/pricing {:compile (fn [schema _]
                                   (let [m (:multiplier (m/properties schema))]
                                     (fn [x] (* x m))))}}]

;; Named custom transformer
(mt/transformer {:name :pricing})

12. Error Messages

Custom Messages

;; Static message
[:int {:error/message "must be a whole number"}]

;; Dynamic message
[:fn {:error/fn (fn [{:keys [value]} _]
                  (str value " is not valid"))}
 pos-int?]

;; Localized
[:enum {:error/message {:en "invalid size" :nl "ongeldige maat"}}
 "S" "M" "L" "XL"]

;; Targeted error path
[:fn {:error/message "must be after start date"
      :error/path [:end-date]}
 (fn [{:keys [start-date end-date]}]
   (.after end-date start-date))]

Spell Checking (Closed Maps)

(-> [:map [:street :string] [:city :string]]
    mu/closed-schema
    (m/explain {:streeet "Kerkstraat" :city "Amsterdam"})
    me/with-spell-checking
    me/humanize)
;; => {:streeet ["should be spelled :street"]}

Error Values Only

;; Just the erroneous parts
(-> schema (m/explain value) me/error-value)

;; With masking
(-> schema (m/explain value) (me/error-value {::me/mask-valid-values '...}))

13. Schema Manipulation (malli.util)

(require '[malli.util :as mu])

;; Navigate
(mu/get-in Address [:address :lonlat])

;; Modify
(mu/assoc-in Address [:address :country] [:enum "NL" "BE"])
(mu/dissoc Address :tags)
(mu/update-properties Address assoc :title "Address")

;; Optional/Required
(mu/optional-keys [:map [:x :int] [:y :int]])
(mu/optional-keys [:map [:x :int] [:y :int]] [:x])
(mu/required-keys [:map [:x {:optional true} :int]] [:x])

;; Open/Close
(mu/closed-schema some-map-schema)  ;; recursive
(mu/open-schema some-map-schema)    ;; recursive

;; Merge (last wins)
(mu/merge
  [:map [:name :string] [:status [:enum "a" "b"]]]
  [:map [:status :string] [:extra :int]])
;; => [:map [:name :string] [:status :string] [:extra :int]]

;; Union (both valid)
(mu/union
  [:map [:x [:enum 1 2]]]
  [:map [:x [:enum 2 3]]])
;; => [:map [:x [:or [:enum 1 2] [:enum 2 3]]]]

;; Select keys
(mu/select-keys Address [:id :tags])

14. Generation

(require '[malli.generator :as mg])

;; Single value
(mg/generate :string)
(mg/generate [:int {:min 0 :max 100}])
(mg/generate Product)

;; Multiple samples
(mg/sample [:enum "NL" "BE"] {:size 5})

;; Control generation
[:set {:gen/max 3} :keyword]
[:vector {:gen/min 1, :gen/max 5} :int]

;; Custom generator
[:int {:gen/gen (gen/return 42)}]
[:string {:gen/fmap clojure.string/upper-case}]

15. Schema Inference (Provider Workflow)

(require '[malli.provider :as mp])

;; Infer from samples
(mp/provide [{:id 1 :name "Widget" :price 2.5}
             {:id 2 :name "Gadget" :price 3.0 :note "limited"}])
;; => [:map
;;     [:id :int]
;;     [:name :string]
;;     [:price :double]
;;     [:note {:optional true} :string]]

Infer → Review → Idealize

The provider gives a rough starting schema. Manually tighten it:

;; 1. Infer from raw data
(def inferred (mp/provide sample-data))
;; => [:map [:type :string] [:price :string] [:id :string] ...]

;; 2. Idealize: tighten types, add constraints
(def idealized
  [:map
   [:type [:= "snapshot"]]
   [:price [decimal? {:min 0}]]   ;; string → decimal with constraint
   [:id :uuid]                     ;; string → uuid
   [:status [:enum "NEW" "WORKING" "CANCELLED"]]])  ;; string → enum

;; 3. Coerce raw data through idealized schema
(m/coerce idealized raw-value mt/string-transformer)

This workflow is particularly useful for extraction pipelines where input data arrives as loose strings.

16. JSON Schema Output

(require '[malli.json-schema :as json-schema])

(json-schema/transform Product)
;; => {:type "object"
;;     :properties {:id {:type "string" :format "uuid"} ...}
;;     :required [:id :name :price :category]}

17. Function Schemas

;; Arrow syntax (simple)
[:-> :int :int]
[:-> :int :int :int]

;; Full syntax
[:=> [:cat :int :int] :int]
[:=> [:cat :string [:* :int]] :string]

;; Multi-arity
[:function
 [:=> [:cat :int] :int]
 [:=> [:cat :int :int] :int]]

;; Annotate functions
(defn calculate-total [items]
  (reduce + (map :price items)))

(m/=> calculate-total [:=> [:cat [:vector Product]] :double])

;; Inline annotation
(defn plus1
  "Adds one"
  {:malli/schema [:=> [:cat :int] :int]}
  [x] (inc x))

;; Instrumentation (dev only)
(require '[malli.dev :as dev])
(require '[malli.dev.pretty :as pretty])
(dev/start! {:report (pretty/reporter)})
(dev/stop!)

;; Emit clj-kondo configs from function schemas
(require '[malli.clj-kondo :as mc])
(mc/emit!)

18. Custom Schemas with -simple-schema

For domain-specific types, use m/-simple-schema to bundle predicate + transformer:

(defn -url-schema []
  (m/-simple-schema
    {:type :url
     :pred (fn [x] (instance? java.net.URL x))
     :type-properties
     {:decode/string
      (fn [s]
        (when (string? s)
          (try (java.net.URL. s)
               (catch Exception _ s))))}}))

;; Use directly
(m/validate (-url-schema) (java.net.URL. "https://example.com"))
;; => true

;; With coercion from string
(m/coerce (-url-schema) "https://example.com" mt/string-transformer)
;; => #object[URL "https://example.com"]

;; Register in a custom registry
(def my-registry
  (merge (m/default-schemas)
         {:url (-url-schema)}))

(m/validate :url (java.net.URL. "https://example.com") {:registry my-registry})

19. Registries

;; Local registry (schema-level)
(m/validate
  [:map {:registry {::item-id :uuid
                    ::status [:enum "active" "archived"]}}
   [::item-id]
   [::status]]
  {::item-id (random-uuid) ::status "active"})

;; Custom registry
(def my-registry
  (merge
    (m/default-schemas)
    (mu/schemas)
    {::pos-price [:and :double [:fn pos?]]
     ::item-name [:string {:min 1, :max 200}]}))

(m/validate ::pos-price 9.99 {:registry my-registry})

20. Practical Patterns

API Request Validation

(def CreateOrderRequest
  [:map {:closed true}
   [:tenant-id :uuid]
   [:customer-name [:string {:min 1}]]
   [:items [:vector {:min 1}
            [:map
             [:product-id :uuid]
             [:quantity pos-int?]
             [:notes {:optional true} :string]]]]
   [:delivery-date :string]
   [:special-instructions {:optional true} :string]])

(def coerce-order
  (m/coercer CreateOrderRequest
    (mt/transformer
      mt/strip-extra-keys-transformer
      mt/json-transformer)))

Multi-tenant Config Validation

(def TenantConfig
  [:map
   [:tenant-id :uuid]
   [:locale [:enum "nl-NL" "nl-BE" "fr-BE" "en-GB"]]
   [:currency [:enum "EUR" "GBP"]]
   [:features [:map
               [:ai-catalog {:optional true} :boolean]
               [:custom-branding {:optional true} :boolean]]]
   [:pricing [:multi {:dispatch :model}
              [:fixed [:map [:model [:= :fixed]] [:price-per-month :double]]]
              [:usage [:map [:model [:= :usage]] [:price-per-order :double]]]]]])

Structured Extraction Schema (AI Pipeline)

(def ExtractedProduct
  [:map
   [:name [:string {:min 1}]]
   [:description {:optional true} :string]
   [:price [:maybe :double]]
   [:unit {:optional true} [:enum "piece" "kg" "liter" "portion"]]
   [:category {:optional true} :string]
   [:allergens {:optional true} [:set [:enum
                                       "gluten" "dairy" "nuts" "eggs"
                                       "soy" "fish" "shellfish"]]]])

(def ExtractionResult
  [:map
   [:products [:vector ExtractedProduct]]
   [:confidence :double]
   [:source-format [:enum "pdf" "excel" "image" "text"]]
   [:warnings {:optional true} [:vector :string]]])

(def validate-extraction (m/validator ExtractionResult))
(def coerce-extraction
  (m/coercer ExtractionResult
    (mt/transformer
      mt/json-transformer
      mt/strip-extra-keys-transformer)))

Database Value Coercion (MySQL)

(def mysql-transformer
  (mt/transformer
    {:name :mysql
     :decoders {:boolean (fn [x]
                           (cond
                             (instance? Boolean x) x
                             (number? x) (not (zero? x))
                             :else x))
                :double (fn [x]
                          (if (instance? java.math.BigDecimal x)
                            (.doubleValue ^java.math.BigDecimal x)
                            x))
                :int (fn [x]
                       (if (instance? Long x)
                         (int x)
                         x))}}))

21. Pretty Development Errors

(require '[malli.dev :as dev])
(require '[malli.dev.pretty :as pretty])

(dev/start! {:report (pretty/reporter)})
(pretty/explain Product {:id "bad"})

22. Walking Schemas

(m/walk
  [:map [:x :int] [:y :string]]
  (m/schema-walker
    (fn [schema]
      (mu/update-properties schema assoc :walked true))))

(mu/subschemas Product)

(mu/find-first some-schema
  (fn [schema _ _]
    (-> schema m/properties :my-prop)))

23. Naming Conventions (Best Practices)

  • PascalCase for def'd schemas: User, CampaignID, OrderItem
  • ::kebab-case for registry keyword refs: ::query-params, ::item-id
  • Consistent acronym casing: UserID not UserId, URL not Url
  • No -schema suffix: User not UserSchema (doubly true in *.schema namespaces)
  • Domain names over technical names: CampaignID not IntRange100 — schemas model domain entities
;; Good
(def CampaignID [:int {:max 100 :min 0}])
(def Message [:map [:campaign-id CampaignID]])

;; If the range truly appears in multiple unrelated domains, name both
(def IntRange0To100 [:int {:max 100 :min 0}])
(def CampaignID IntRange0To100)
(def ProgressPercent IntRange0To100)

24. Make Illegal States Impossible

Avoid optional keys that create invalid combinations. Split into explicit variants:

;; BAD: 4 possible states, only 2 are valid
[:map
 [:common :string]
 [:from-a {:optional true} SchemaA]
 [:from-b {:optional true} SchemaB]]

;; GOOD: exactly 2 valid states
(def Common [:map [:common :string]])
(def FromA (mu/merge Common [:map [:from-a SchemaA]]))
(def FromB (mu/merge Common [:map [:from-b SchemaB]]))
(def Message [:or FromA FromB])

This also produces better generators — no invalid combinations in test data.

Prefer Narrowest Schema

Always ask: does this make sense for the domain?

;; Can you receive negative messages? No.
[:map [:messages-received int?]]        ;; too broad
[:map [:messages-received nat-int?]]    ;; zero or more
[:map [:messages-received pos-int?]]    ;; one or more (if 0 is invalid)

Prefer Built-ins Over Ad-hoc

;; Good: built-in with constraints
[:int {:min 0 :max 100}]
[:string {:max 256}]

;; Bad: ad-hoc fn predicates
[:and int? [:fn #(and (<= 0 %) (<= % 100))]]
[:and string? [:fn #(>= 256 (count %))]]

Built-ins give you better error messages, generators, and performance for free.

25. Performance Tips

  1. ~20x faster than spec for validation, thanks to compiled validator functions.
  2. Precompile everything: m/validator, m/decoder, m/encoder, m/explainer, m/coercer — never call m/validate or m/decode in hot paths without precompiling.
  3. Use :sequential over [:* type] when you just need homogeneous collection validation.
  4. Use :every over :seqable for large collections where sampling is acceptable.
  5. Compose transformers once with mt/transformer rather than running multiple passes.
  6. Closed maps validate faster than open maps when you know the shape.
  7. :multi with :dispatch is faster than chained :or for polymorphic maps.

Quick Reference: Common Operations

OperationFunctionReturns
Validate(m/validate schema value)boolean
Validate (fast)((m/validator schema) value)boolean
Explain(m/explain schema value)nil or error map
Humanize(me/humanize (m/explain ...))human-readable map
Parse(m/parse schema value)tagged value or :malli.core/invalid
Unparse(m/unparse schema value)original value
Decode(m/decode schema value transformer)decoded value
Encode(m/encode schema value transformer)encoded value
Coerce(m/coerce schema value transformer)value or throws
Generate(mg/generate schema)random value
Infer(mp/provide [samples...])schema
Merge(mu/merge schema1 schema2)merged schema
JSON Schema(json-schema/transform schema)JSON Schema map
Form(m/form schema)vector syntax
AST(m/ast schema)map syntax
Properties(m/properties schema)properties map
Children(m/children schema)child schemas
Type(m/type schema)schema type keyword