Malli Schema
Why Schemas Matter
- Validation: Catch errors early with runtime validation (in dev/test)
- Documentation: Schemas serve as executable, always-up-to-date documentation — they reduce the need for type descriptions in docstrings
- Generative Testing: Enable property-based testing with generated test data
- Zero Runtime Cost: Store schemas as metadata — no production dependency on Malli
- Better Error Messages: Clear validation errors show exactly what's wrong
Schema Organization
Function Schemas
- Add as
:malli/schemametadata on the function itself - Keep Malli as dev-only dependency (no runtime impact)
- Metadata travels with the function definition
Data Structure Schemas
- Define in separate schema namespace (e.g.,
my-app.schema) - Group related schemas in registry functions
- Register in test fixtures for validation
Schema Location Pattern
src/
my_app/
core.clj # Functions with :malli/schema metadata
schema.clj # Data structure schemas + registries
test/
my_app/
core_test.clj # Registers schemas in fixtures
Adding Function Schemas
Critical Rule: Always Use :catn
ALWAYS use :catn (not :cat) for function parameters to provide descriptive names. This makes schemas self-documenting — :cat leaves parameters unnamed, producing unclear error messages and unreadable schemas.
Template
(defn my-function
"Function docstring explaining what it does"
{:malli/schema [:=> [:catn
[:param-name :param-type]
[:other-param :other-type]]
:return-type]}
[param-name other-param]
;; implementation
)
Why :catn vs :cat
Bad — Using :cat:
{:malli/schema [:=> [:cat :string :string] :string]}
- Unclear which parameter is which
- No self-documentation
- Harder to debug validation errors
Good — Using :catn:
{:malli/schema [:=> [:catn
[:uri :string]
[:root-path :string]]
:string]}
- Immediately clear what each parameter represents
- Self-documenting
- Better error messages
Examples
Simple function:
(defn add
"Adds two numbers"
{:malli/schema [:=> [:catn
[:x :int]
[:y :int]]
:int]}
[x y]
(+ x y))
Function with optional parameters:
(defn fetch-user
"Fetches user with optional timeout"
{:malli/schema [:=> [:catn
[:user-id :string]
[:options [:map
[:timeout {:optional true} :int]]]]
[:maybe :map]]}
[user-id options]
;; implementation
)
Function returning maybe/optional:
(defn find-by-id
"Finds entity by ID, returns nil if not found"
{:malli/schema [:=> [:catn [:id :int]]
[:maybe :map]]}
[id]
;; implementation
)
Higher-order function (returns function):
(defn make-adder
"Creates a function that adds n to its input"
{:malli/schema [:=> [:catn [:n :int]]
[:fn #(fn? %)]]}
[n]
(fn [x] (+ x n)))
Multi-arity function:
(defn greet
"Greets a person, optionally with title"
{:malli/schema [:function
[:=> [:catn [:name :string]] :string]
[:=> [:catn [:title :string] [:name :string]] :string]]}
([name]
(str "Hello, " name))
([title name]
(str "Hello, " title " " name)))
Single-argument function (still use :catn for consistency):
(defn square
"Squares a number"
{:malli/schema [:=> [:catn [:n :int]]
:int]}
[n]
(* n n))
Adding Data Structure Schemas
1. Define the Schema
In your schema namespace (e.g., my_app/schema.clj):
(ns my-app.schema
(:require [malli.core :as m]))
(def user?
"Schema for user data structure"
[:map
[:id :int]
[:name :string]
[:email [:string {:min 5}]]
[:age {:optional true} :int]
[:roles [:set :keyword]]])
(def http-request?
"Schema for HTTP request"
[:map
[:method [:enum :get :post :put :delete]]
[:uri :string]
[:headers {:optional true} [:map-of :string :string]]
[:body {:optional true} :any]])
2. Create Registry Function
Group related schemas:
(defn user-schemas []
{:user user?
:http-request http-request?})
(defn validation-schemas []
{:email-regex #"^[^@]+@[^@]+\.[^@]+$"
:phone-regex #"^\d{3}-\d{3}-\d{4}$"})
3. Register in Tests
In your test file, register schemas in fixtures:
(ns my-app.core-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[my-app.schema :as app-schema]
[malli.core :as m]
[malli.dev :as mdev]
[malli.registry :as mr]))
(use-fixtures :once
(fn [f]
(mr/set-default-registry!
(merge
(m/comparator-schemas)
(m/type-schemas)
(m/sequence-schemas)
(m/base-schemas)
(app-schema/user-schemas)
(app-schema/validation-schemas)))
(mdev/start!) ;; Enable dev-mode instrumentation
(f)
(mdev/stop!)
(mr/set-default-registry! m/default-registry)))
Schema Checklist for New Code
When adding or modifying code, ensure:
- All public functions have
:malli/schemametadata - Function schemas use
:catn(not:cat) with descriptive parameter names - All data structures passed between functions are defined in schema namespace
- New schemas are added to appropriate registry function
- Test registries are updated to include new schema groups
- Tests pass (validates schemas work correctly)
- Schemas use custom types from registry (not just primitives) when applicable
Verification Workflow
Before committing code:
Run tests — schemas are validated automatically when dev-mode is active
# For devbox projects: devbox run -- bb testCheck Malli dev-mode output — look for:
- "instrumented N function vars" message
- No schema-related errors or warnings
- All expected functions instrumented
Verify schema errors are helpful — if validation fails:
- Error message should clearly indicate which parameter failed
- Should show expected vs actual types
:catnnames should appear in error messages
Project Setup
Initial Malli Setup
Add Malli as a dev dependency (use the latest stable version from Malli releases):
deps.edn:
{:deps {}
:aliases
{:dev {:extra-deps {metosin/malli {:mvn/version "LATEST"}}}
:test {:extra-deps {metosin/malli {:mvn/version "LATEST"}}}}}
Create Schema Namespace
(ns my-app.schema
"Malli schemas for data validation"
(:require [malli.core :as m]))
;; Define your schemas here
(defn all-schemas []
"Registry of all application schemas"
{})
Best Practices
- Add schemas as you write code — don't retrofit later
- Use
:catnalways — even for single-arg functions - Keep schemas close to data — define in schema namespace, use in code
- Use descriptive parameter names —
[:uri :string]not[:s :string] - Prefer custom types over primitives — create
:user-idtype instead of using:inteverywhere - Test schema validation — write tests that intentionally violate schemas
- Update schemas with code changes — keep them in sync
- Use dev-mode in tests — catches schema violations early
Common Pitfalls to Avoid
Using :cat instead of :catn:
{:malli/schema [:=> [:cat :string :int] :string]} ;; Bad!
Forgetting to register custom schemas:
;; Defined :user-id in schema.clj but forgot to add to registry
{:malli/schema [:=> [:catn [:id :user-id]] :map]} ;; Will fail!
Missing metadata key:
(defn foo [x]
[:=> [:catn [:x :int]] :int] ;; Wrong! Not in metadata
(+ x 1))
Not using dev-mode in tests:
;; Missing (mdev/start!) in test fixture
;; Schemas defined but never validated!
Troubleshooting
"Schema not found: :my-type"
- Check that the schema is defined in your schema namespace
- Verify the registry function includes the schema
- Ensure test fixtures register your schemas
"Unable to resolve symbol: mdev"
- Add Malli to dev/test dependencies
- Require
[malli.dev :as mdev]in test namespace
Functions not being instrumented
- Verify
(mdev/start!)is called in test fixture - Check that registry is set before dev-mode starts
- Ensure function has
:malli/schemametadata (not just a schema)
Validation errors are unclear
- Switch from
:catto:catnfor better error messages - Add
:titleor:descriptionto schemas for clarity - Use specific custom types instead of generic primitives
For common schema patterns and a quick reference card, read references/malli-patterns.md.