Malt is a small layer on top of Clojure protocols and records that lets you attach Malli schemas to protocol methods and record constructors. The output is a native Clojure protocol or record, so tooling and language features keep working, but you also gain the ability to perform runtime validation of inputs, outputs, and declared error conditions (checked exceptions). The result is a protocol interface that is concrete, self-documenting, and usable as a real boundary between parts of a system.
The intent is to make it easy to express contracts between components without introducing a heavy type system. If you already use Malli, you can reuse its schemas for validation, documentation, and generation. If you just want runtime checks for tricky boundaries, you can use the macros only in those places.
Malt fully integrates with clj-kondo enabling deep editor integration into the macro syntax. All built-in language
features for protocols and records such as find-references, find-definitions, and find-implementations continue to
work the way you would expect. Your editor will understand the syntax out-of-the-box with no need for finiky
configurations or tweaks.
(ns example
(:require
[io.julienvincent.malt :as malt]
[malli.core :as m]))
(def ?Plumburg
[:map
[:name :string]
[:edges :int]])
;; Define a protocol using parameter/schema pairs.
(malt/defprotocol Api
(create-plumburg [name :string edges :int]
?Plumburg))
;; Using `malt/defrecord` works identically to `clojure.core/defrecord` but
;; overrides the generated `->Type` and `map->Type` constructors to add
;; schema validation.
(malt/defrecord Service
[db ?DataSource]
Api
(create-plumburg [_service name edges]
(write-to-db db name edges)))
(create-plumburg (->Service db) "fred" "2")
;; => throws input validation exception
(defn create-service [db]
;; Use `malt/reify` instead of `clojure.core/reify` to implement a protocol
;; with schema validation
(malt/reify Api
(create-plumburg [_ name edges]
(write-to-db db name edges))))
(defn create-service-with-reify [db]
;; You can still use native `clojure.core/reify`, just without the schema
;; validation
(reify Api
(create-plumburg [_ name edges]
(write-to-db db name edges))))
;; Some top-level malli schemas are also exported as vars which can be used to
;; validate record types.
(m/validate ?Service (->Service db))
malt/defprotocol stores Malli schemas in the protocol var metadata. Later, when an implementation is created with
malt/extend-type or malt/reify, those implementations wrap the method bodies with validation. A method call
validates the inputs against the argument schema and validates the return value against the return schema. Validation
failures are raised as ExceptionInfo with structured error data.
malt/defrecord behaves like clojure.core/defrecord but also validates record constructors. Additionally, if you
inline protocol implementations inside a malt/defrecord, those implementations are also wrapped when the protocol
being implemented was defined with malt/defprotocol.
malt/defprotocolUsed to define a typed Clojure protocol. The protocol remains native, but the schema metadata lets malt attach validators and makes the contract visible to tools and humans.
(throws [...]) clause after the return schema to declare checked exceptions - see
Checked exceptions.?ProtocolName Malli schema var.(def not-found
{:code :not_found
:message "User not found"
:schema [:map
[:id :string]]})
(malt/defprotocol UserStore
(create-user [name :string age :int] :string)
(delete-user [id :string] :nil)
(suspend-user! [id :string]
:nil
(throws [not-found])))
Method definitions differ slightly from clojure.core/defprotocol in that the docstring and metadata needs to be placed
before the params vector, instead of after. This makes the definition read more like defn and makes the return schema
clearer.
(malt/defprotocol UserStore
(create-user
"Create a new user and return the id."
{:audit/event :user.created}
[name :string age :int]
:string))
Additionally, the this parameter from clojure.core/defprotocol is completely omitted as we consider it unnecessary
due to being required by every method.
Exports:
UserStore: the protocol var.?UserStore: Malli schema that checks satisfies? for the protocol.The resulting Clojure protocol has additional data associated with it. This data is what is used by malt/reify,
malt/extend-type, and malt/defrecord to augment implementations with schema validations.
The data is considered part of the public API and it is fully the expectation that other tools, and you, can use the protocol data to build on top of.
The protocol var (accessed by #'ProtocolVar) stores a :sigs map containing the underlying Clojure method
definitions, and malt additionally stores namespaced data there as well.
You can verify if a protocol is a malt protocol by checking the :malt/protocol field on the var:
(:malt/protocol #'UserStore)
Evaluating the protocol var shows the stored sigs:
#'UserStore
{:malt/protocol true
:sigs
{:create-user
{:malt/params [name age]
;; A map of :param-name -> Malli schema, useful for tools
:malt/param-schemas {:name :string
:age :int}
;; A prepared schema for validating a function call arguments
:malt/arguments-schema [:cat :string :int]
:malt/return-schema :string
;; These are precompiled malli validators via `(m/validator ?schema)`
:malt/arguments-validator #object[...]
:malt/return-validator #object[...]}
:delete-user
{:malt/params [id]
:malt/param-schemas {:id :string}
:malt/arguments-schema [:cat :string]
:malt/return-schema :nil
:malt/arguments-validator #object[...]
:malt/return-validator #object[...]}
:suspend-user!
{...
;; The resolved error definition maps from the (throws [...]) clause
:malt/throws [{:code :not_found
:message "User not found"
:schema [:map [:id :string]]}]
;; A map of error code -> precompiled Malli validator for the definition's :schema
:malt/exception-validators {:not_found #object[...]}}}}
malt/defrecordInline protocol implementations are validated when the protocol was defined with malt/defprotocol. This lets records
serve as concrete, validated implementations while still validating their own construction.
(malt/defrecord UserStoreImpl
[db ?DataSource]
UserStore
(create-user [_ name age]
(persist-user db name age))
(delete-user [_ id]
(delete-user! db id)))
->Record and map->Record to validate constructor inputs.?RecordSchema (map shape) and ?Record (instance check) schemas.Exports:
UserStoreImpl: the record type.->UserStoreImpl: validated positional constructor.map->UserStoreImpl: validated map constructor.?UserStoreImplSchema: Malli :map schema for the record fields.?UserStoreImpl: Malli schema that checks instance? for the record.malt/extend-typeThis is the main way to attach validation to concrete types without changing how you structure code. You can continue to extend classes and records, but get consistent validation and error data at the protocol boundary.
malt/extend-type supports passing a var for a record in another namespace, which is a deviation from
clojure.core/extend-type. If you pass a qualified record var (for example other.ns/SomeRecord), malt resolves it to
the underlying class so you can extend external records without manually constructing the class name.
(malt/extend-type UserStoreImpl
UserStore
(create-user [store name age]
(persist-user (:db store) name age))
(delete-user [store id]
(delete-user! (:db store) id)))
clojure.core/extend-type.extend-type result with runtime validation on calls.malt/reifyThis is useful for tests, adapters, and small inline implementations where you still want the protocol contract enforced at runtime.
(def in-memory-store
(malt/reify UserStore
(create-user [_ name age]
(swap! users conj {:name name :age age})
(java.util.UUID/randomUUID))
(delete-user [_ id]
(swap! users (partial remove #(= (:id %) id)))
nil)))
clojure.core/reify.The exceptions thrown by an interfaces methods are just as much a part of the interface as the input and output. Clojure makes this really hard to describe, and this is something malt tries to better address through the use of java-style checked exceptions.
While personally I believe the errors-as-results (rust-style) to be a better pattern when working with exceptions, I also believe that you should use the underlying primitives of the language. Things can quickly become a mess if you try to completely re-define the way a core language construct like errors works.
Thus we simply expose (as much as possible) the underlying java semantics of checked exceptions in a way that is as least invasive as possible.
Our malt interface defines a boundary between systems or components, and the checked exceptions help maintain that boundary.
Protocol methods can declare the errors they are expected to throw using a (throws [...]) clause placed after the
return schema. Each symbol in the vector must resolve to a var holding an error definition map matching
io.julienvincent.malt.error/?ErrorDefinition:
[:map {:closed true}
[:code :keyword]
[:message {:optional true} :string]
[:schema {:optional true} :any]
[:metadata {:optional true} :map]]
Definitions are resolved and validated when the protocol is defined. Invalid definitions throw a
:malt/invalid-definition error at definition (compile) time.
(require '[io.julienvincent.malt.error :as malt.error])
(def not-found
{:code :not_found
:message "Resource not found"
:schema [:map
[:id :string]]
:metadata {:http/status-code 404}})
(malt/defprotocol Resources
(fetch! [id :string]
?Resource
(throws [not-found])))
(malt/defrecord ResourceStore
[db ?DataSource]
Resources
(fetch! [_ id]
(or (lookup db id)
(malt.error/throw! not-found {:id id}))))
Malt errors are ExceptionInfo instances with {:type :malt/error :code <code> :data <map>} as ex-data. Use
malt.error/ex to construct one, or malt.error/throw! to construct and throw in one step. Both accept the same
arities:
(ex definition) - uses the definition's :message; throws if the definition has none.(ex definition data) - attaches a data map.(ex definition message) - overrides the definition's :message.(ex code message) - constructs from a bare keyword code; a message is required.(ex code|definition message data cause) - full form, attaching a cause exception.When a method declaring a throws clause is implemented via malt/reify, malt/extend-type, or inline in a
malt/defrecord, exceptions escaping the method body are checked against the declared definitions:
:code matches a declared definition, and whose :data validates against the definition's
:schema (when present), is re-thrown unchanged.:data is wrapped in a :malt/invalid-exception-error.:malt/unspecified-exception-error.The original exception is always preserved as the ex-cause of the wrapping exception. Methods without a throws
clause are unaffected and exceptions pass through unchanged.
The error and their full definitions are exposed on the underlying protocol var metadata under a :malt/throws key.
This data can and should be used by external tools to generate clients, openapi schemas, or things like HTTP endpoint
definitions.
Error definitions have a dedicated :metadata map which is there for you to store whatever additional data you want.
This metadata is designed to be consumed by generators.
For example, you could define your error definitions as follows:
(def not-found
{:code :not_found
:message "Resource not found"
:schema [:map
[:id :string]]
;; HTTP status code added as additional metadata
:metadata {:http/status-code 404}})
And this metadata can be consumed by, say, an openapi schema generator to produce appropriate results associated with each respective HTTP status code.
Validation failures throw ExceptionInfo with a :type in ex-data that you can reliably switch on. The intent is
that you can log or surface these errors without additional translation. The error data is structured enough to be
inspected and rendered usefully in tests and runtime logs.
ExceptionInfo.ex-data includes :type and contextual keys like :protocol, :method, :record, :constructor, :input,
:output, :data, and :errors.:errors is produced by malli.error/humanize.Error types (constructor-form examples):
:malt/input-validation-failedProtocol input validation failed.
(ex-info
"Invalid parameter 'name' passed to 'create-user' of example/UserStore"
{:type :malt/input-validation-failed
:protocol 'example/UserStore
:method 'create-user
:input [123 '_]
:errors [["should be a string"]]})
(ex-info
"Invalid parameter 'age' passed to 'create-user' of example/UserStore"
{:type :malt/input-validation-failed
:protocol 'example/UserStore
:method 'create-user
:input ['_ "not-an-int"]
:errors [nil ["should be an integer"]]})
:malt/output-validation-failedProtocol output validation failed.
(ex-info
"Invalid return value from 'delete-user' of example/UserStore"
{:type :malt/output-validation-failed
:protocol 'example/UserStore
:method 'delete-user
:output 1
:errors ["should be nil"]})
:malt/record-validation-failedRecord constructor validation failed.
(ex-info
"Invalid parameter passed to constructor '->UserStoreImpl' of example/UserStoreImpl"
{:type :malt/record-validation-failed
:record 'example/UserStoreImpl
:constructor '->UserStoreImpl
:input [1]
:errors [["should satisfy ?DataSource"]]})
:malt/unspecified-exception-errorA method declaring a throws clause threw an exception that was not declared. The original exception is attached as the
ex-cause.
(ex-info
"Unspecified exception thrown from method 'fetch!' of example/Resources"
{:type :malt/unspecified-exception-error
:protocol 'example/Resources
:method 'fetch!})
:malt/invalid-exception-errorA declared error was thrown but its :data did not match the definition's :schema. The original exception is attached
as the ex-cause.
(ex-info
"Invalid exception thrown from method 'fetch!' of example/Resources"
{:type :malt/invalid-exception-error
:protocol 'example/Resources
:method 'fetch!
:data {:id 1}
:errors {:id ["should be a string"]}})
:malt/invalid-definitionAn error definition referenced from a throws clause did not match ?ErrorDefinition. This is thrown at protocol
definition time.
(ex-info
"Invalid error definition"
{:type :malt/invalid-definition
:definition {:message "Foo"}
:errors {:code ["missing required key"]}})
If you are using the latest version of clojure-lsp (anything after 2025.11.28-12.47.43) then the indent metadata on
malts APIs should normally be sufficient and everything should format correctly out of the box.
If you are formatting using the CLI then you need to make sure to set :type :project-only for clojure-lsp to consider
the :style/indent API metadata during formatting.
clojure-lsp format --analysis '{:type :project-only}'
If you are using an older version of clojure-lsp, are running into unexplained formatting issues, or simply want to use
cljfmt on its own then you can instead apply the following cljfmt indent config:
;; .cljfmt.edn
{:extra-indents {io.julienvincent.malt/extend-type [[:inner 0] [:inner 1]]
io.julienvincent.malt/reify [[:inner 0] [:inner 1]]
io.julienvincent.malt/defprotocol [[:inner 0] [:inner 1]]
io.julienvincent.malt/defrecord [[:inner 0] [:inner 1]]}}
If you are using the autogenerated ?Type schemas from malt/defprotocol and malt/defrecord, note that clojure-lsp
does not always index those vars as expected. Go-to-references and rename may not include those schema vars.
See clojure-lsp issues:
Prismatic Schema implemented a defprotocol for their schema language. They jumped through many hoops to get native
clojure reify, extend-type, etc. working with schema validation without custom macros. That comes at the cost of
performance because it disables method short-circuiting using var :inline metadata. malt uses a less native approach
in exchange for better runtime performance.
See implementation here: schema/macros.cljL417.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |