~ / track C / clojure ecosystem

Malli schemas

Intermediate

Malli is the modern Clojure schema library. Schemas are plain data, so you can transform them, generate from them, validate against them, and explain errors with them. Compared to ad-hoc validation code, a Malli schema is a single source of truth: one definition feeds runtime validation, generative testing, JSON-schema export, and documentation.

Note: examples below describe metosin/malli. The SCI REPL on this page does not include it; run the examples in a real Clojure project.

Minimal example

A schema is data — a keyword, a vector form, or a fn. validate returns a boolean, explain returns details:

(require '[malli.core :as m])

(m/validate :int 7)
;; => true

(m/validate [:vector :int] [1 2 3])
;; => true

(m/explain [:map [:name :string] [:age :int]]
           {:name "ana" :age "old"})
;; => {:errors [{:path [:age] :in [:age] :schema :int :value "old"}]}

:map, :vector, :set, :and, :or, :enum, :tuple, :maybe, and function/value predicates cover most needs.

Composing schemas

Schemas combine like any other data. You can def and reuse them:

(def Person
  [:map
   [:name :string]
   [:age [:int {:min 0 :max 130}]]
   [:roles [:set [:enum :admin :member :guest]]]])

(m/validate Person {:name "ana" :age 31 :roles #{:admin}})
;; => true

A schema can refer to another schema by symbol — that's how you build a library of named types.

Closed maps and :keys discipline

By default :map is open — extra keys are allowed. For APIs you usually want the stricter :map with :closed true:

[:map {:closed true}
 [:name :string]
 [:age  :int]]

Now {:name "ana" :age 31 :extra :stuff} fails validation, which catches typos and stale fields at the boundary.

Coercion and decoding

Schemas double as decoders. A request body comes in as strings; Malli's transformers can coerce to the declared types, returning the typed value or an error:

(require '[malli.transform :as mt])

(m/decode [:map [:age :int]]
          {:age "31"}
          (mt/string-transformer))
;; => {:age 31}

You write the schema once; validation, coercion, and error messages all flow from it.

Generative testing for free

A Malli schema can produce a test.check generator. That means property tests for any value of this type are one call away:

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

(mg/generate Person)
;; => {:name "..." :age 47 :roles #{:guest}}

(mg/sample Person 5)
;; => five random Persons

Pair this with test.check properties (next topic) and your schemas earn their keep.

Check yourself

? quiz

What's the practical benefit of schemas being plain Clojure data?

Exercise

Write a Malli schema Order with the fields:

  • :id — non-empty string
  • :items — vector of at least one entry, each :map with :sku :string and :qty :int (positive)
  • :vip? — boolean, optional

Validate the example below; then add {:closed true} and confirm an extra top-level key now fails.

(def Order ,,, )

(m/validate Order
  {:id "ord-1"
   :items [{:sku "abc" :qty 2}]
   :vip? true})
 status: new