A function's input/output contract and its examples, written next to the function.
Most languages give you one familiar shape for a typed function: name, inputs with their types, output type, body.
TypeScript
function orderTotal({ price, qty = 1, discount = 0 }:
{ price: number; qty?: number; discount?: number }): number
Kotlin
fun orderTotal(price: Int, qty: Int = 1, discount: Int = 0): Int
Swift
func orderTotal(price: Int, qty: Int = 1, discount: Int = 0) -> Int
Python
def order_total(price: int, qty: int = 1, discount: int = 0) -> int:
Clojure has no such form. «Is Clojure typed?» has no short answer: yes, sort of, but the popular ways to get there have an API no human wants to read next to their code. malli already solves the hard part (schemas, validation, readable errors, instrumentation), so this library only changes how you write it down. What the function does, then example inputs and outputs, then the typed function: plain data, in that order, nothing else.
(defn-typed order-total {
:price [:int {:min 1}]
:qty [:int {:min 1 :default 1}]
:discount [:int {:min 0 :max 100 :default 0}]
} -> :int
Same shape, and the signature also says what none of the four can: discount is 0 to 100. The
range is checked at runtime, on every call, only while malli instrumentation is on: in the REPL
after (malli.dev/start!), in tests after (malli.instrument/instrument!). Uninstrumented code
checks nothing, and cljs release builds contain no malli at all. clj-kondo checks keys and types
(not the range) as you type, once the types are emitted — see Static checking.
defmeta (above the function): the docstring and the :inout-tests — [in out] example pairs.defn-typed (the function): a map input {key schema …}, an -> output schema, then the body.
Every row key is a local in the body, defaults already filled.The schemas are malli schemas, stored as plain :malli/schema
var metadata. They check calls only where a dev/test/REPL loader runs malli's instrumentation;
a production build carries them as data and never loads malli. The example pairs run as tests
(check-var, check-ns, deftests!).
Works in Clojure and ClojureScript (.clj, .cljs, .cljc).
;; deps.edn, from Clojars
{:deps {io.github.hyperfocusdisordered/defn-typed {:mvn/version "0.2.0"}}}
;; deps.edn, from git
{:deps {io.github.hyperfocusdisordered/defn-typed {:git/tag "v0.1.4" :git/sha "0edbba1"}}}
;; shadow-cljs.edn
{:dependencies [[io.github.hyperfocusdisordered/defn-typed "0.2.0"]]}
metosin/malli comes along as a dependency (see malli versions).
clj-kondo reports wrong calls of defn-typed functions once malli has written the functions'
types into the project's .clj-kondo directory. In the consuming project:
# 1. the defn-typed hooks (once, and again after upgrading the library)
mkdir -p .clj-kondo # clj-kondo copies configs only into an existing config dir
clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint
# 2. the types: load your namespaces, collect their schemas, emit (again after a schema change)
clojure -M -e "(require 'my.app.core 'malli.instrument 'malli.clj-kondo) (do (malli.instrument/collect! {:ns (all-ns)}) (malli.clj-kondo/emit!))"
# 3. lint as usual
clj-kondo --lint src
Step 2 writes .clj-kondo/imports/metosin/malli-types-clj/config.edn, which clj-kondo loads with
no further config; it covers functions defined in .clj and .cljc files. With the hooks alone
clj-kondo checks the form's shape and the body, not the types of the calls.
In a REPL, (malli.dev/start!) does step 2 and instruments; after redefining a function, re-run
(malli.instrument/collect! {:ns ['my.app.core]}) to re-emit; (malli.dev/stop!) empties the
types file.
(order-total {:price "100"}) ; error: Expected: integer, received: string.
(order-total {:qty 2}) ; error: Missing required key: :price
Functions defined in .cljs files (shadow-cljs): copy the hooks from
--lint "$(npx shadow-cljs classpath)", and get the types from a small node build that prints
them with malli's print-cljs!:
;; shadow-cljs.edn, under :builds
:kondo-types {:target :node-script :main my.app.kondo-types/main :output-to "out/kondo-types.js"}
;; src/my/app/kondo_types.cljs
(ns my.app.kondo-types
(:require [my.app.core] ; every namespace whose functions get types
[malli.instrument :as mi]
[malli.clj-kondo :as mc]))
(defn main []
(mi/collect! {:ns [my.app.core]}) ; a literal list: cljs collects at compile time
(mc/print-cljs!))
npx shadow-cljs compile kondo-types
mkdir -p .clj-kondo/imports/metosin/malli-types-cljs
node out/kondo-types.js > .clj-kondo/imports/metosin/malli-types-cljs/config.edn
What is checked where:
(order-total {:price "100"});(order-total {:qty 2});(ship {:addr {:zip "x"}}), ship's row
:addr [:map [:zip :int]];(let [p "100"] (order-total {:price p}));(order-total {:price (label {:n 1})}), label being -> :string.{:price 0} against [:int {:min 1}]), unknown keys of a closed map (^{:closed true}), and
the output. clj-kondo's types carry no ranges and read every map as open..clj-kondo, instrumentation only in dev/test.Using Claude Code? examples/claude-code gives the agent this check after every edit.
Read top to bottom: the task, then its inputs and outputs, then the typed function.
(ns example (:require [defn-typed.core :refer [defn-typed defmeta]]))
(defmeta order-total
{:doc "Order total: price × qty, minus a percent discount."
:inout-tests [[{:price 100} 100]
[{:price 100 :qty 3} 300]
[{:price 100 :qty 3 :discount 10} 270]]})
(defn-typed order-total {
:price [:int {:min 1}]
:qty [:int {:min 1 :default 1}]
:discount [:int {:min 0 :max 100 :default 0}]
} -> :int
(quot (* price qty (- 100 discount)) 100)
)
The smallest one, a single input:
(defmeta fizzbuzz
{:doc "FizzBuzz: 'Fizz' for multiples of 3, 'Buzz' for multiples of 5, 'FizzBuzz' for both, else the number as a string."
:inout-tests [[{:n 1} "1"]
[{:n 3} "Fizz"]
[{:n 5} "Buzz"]
[{:n 15} "FizzBuzz"]
[{:n 7} "7"]]})
(defn-typed fizzbuzz {:n :int} -> :string
(cond (zero? (mod n 15)) "FizzBuzz"
(zero? (mod n 3)) "Fizz"
(zero? (mod n 5)) "Buzz"
:else (str n))
)
A real one, from the app this library was extracted from:
(defmeta invite-token-of
{:doc "The invite token a link carries: ?invite=<token>, else the Telegram start parameter
invite-<token> (base64url, the only shape startapp accepts). nil when neither."
:inout-tests [[{:url-token "abc" :start-param nil} "abc"]
[{:url-token "abc" :start-param "invite-other"} "abc"]
[{:url-token nil :start-param "invite-Xy_9-z"} "Xy_9-z"]
[{:url-token "" :start-param "bid-1-2"} nil]
[{:url-token nil :start-param nil} nil]]})
(defn-typed invite-token-of {
:url-token [:maybe :string]
:start-param [:maybe :string]
} -> [:maybe :string]
(or (not-empty url-token)
(second (re-matches #"invite-([A-Za-z0-9_-]+)" (or start-param ""))))
)
The three blocks run as a test (test/defn_typed/readme_test.clj evaluates them verbatim).
(defmeta name {:doc "…" :inout-tests [[in out] …] …other var metadata})
(defn-typed name {key schema …} -> out-schema body…)
defmeta goes before the defn-typed of the same name: the macro reads it to put :doc on
the function. defmeta declares name.
Input = one map literal {key schema …}. Keys are keywords; qualified keys bind by their
name (:x/b → b).
Defaults = :default in the type's own props, :qty [:int {:min 1 :default 1}]; that row
is optional by itself.
Row props key [props schema] (a value vector whose first element is a map) = :optional
without a default; :default there is a compile error.
Output = any malli schema after ->.
Body: no argument vector — every row key is already a local.
Table props go on the map as reader metadata; ^{:as sym} binds the whole defaults-filled
map (keys beyond the rows included — [:map …] is open) to sym:
(defn-typed with-total ^{:closed true :as row} {
:price :int
:qty [:int {:default 1}]
} -> [:map [:total :int]]
(assoc row :total (* price qty))
)
:inout-tests = [in out] pairs, in = the function's single argument (the map; or the
scalar of a one-argument plain defn). A case passes iff (= out (f in)). A non-pair throws
naming the var.
A docstring, an attr-map, an argument vector, or -> with nothing after it inside defn-typed is
a compile error naming the function. Single arity only.
defn-typed expands to plain Clojure:
(do (def name-props [:map [key schema] …]) ; a defaulted row: [key {:optional true} schema]
(defn name--positional [key …] body…)
(defn name {:malli/schema [:=> [:cat name-props] out-schema] :doc … :inline …}
[m]
(let [{:keys [key …] :or {key default …}} m]
(name--positional key …))))
so clj-kondo (with the exported hooks), malli's collect!/instrument!, :arglists and any
tool that reads a defn see a defn. The defaults are the :or of the destructuring, taken from
the rows when the macro expands. With ^{:as row} the map goes through
defn-typed.core/with-defaults (the whole filled map is bound); a row whose defaults only the
evaluated schema shows (a symbol as its type, :frame frame, or a [:map …] row with defaults
inside) is read at call time. <name>-props keeps entry order up to 8 rows (a larger map literal
reads as a hash map; the order is cosmetic).
The body lives in <name>--positional, whose parameters are the rows in entry order; <name>
destructures the map and calls it. With the switch on, a call whose argument is a map literal
compiles straight to the positional call:
(order-total {:price p :qty 3})
;; compiles to
(let [price__1 p qty__2 3] (order-total--positional price__1 qty__2 0))
The values are evaluated in the literal's order, as the map call evaluates them; an absent row
gets its default. Every other call is the map call: a map that is not a literal, a literal with an
unknown key or without a required key, a literal that fails the checks below, apply and
higher-order uses, and every call of a function with ^{:as row} or with a row read at call time.
The switch:
defn-typed.inline=true while the calling code compiles
(clojure -J-Ddefn-typed.inline=true …, :jvm-opts ["-Ddefn-typed.inline=true"]). Every direct
call is covered, :referred ones included (the function's :inline).:optimizations :advanced). Calls through an alias
(c/order-total), a qualified name, or inside the defining namespace are covered; a :referred
call from another namespace stays the map call (clj-kondo and dev instrumentation still check
it). In the release JS such a call builds no map: (c/order-total {:price p :qty 3}) came out
as quot(300 * p, 100).Off everywhere else: dev, REPL, tests. A rewritten call skips the var, so instrumentation would not see it and a redefinition in the REPL would not reach it; with the switch off every call goes through the var.
A 3-row function with 2 defaults, (total {:price p :qty 3}), criterium quick-bench on JVM 21:
positional defn 36 ns, switch off 51 ns, switch on 28 ns (0.1.4, which filled the defaults by
walking the schema at every call: 814 ns).
With the switch on or off, a map-literal call is checked where it compiles: an unknown key, a missing required key, and each value that is data (a number, string, keyword, boolean, nil, or a literal collection of those) against its row schema, ranges included. A mismatch prints one line to stderr and the call compiles to the map call; the build goes on:
WARNING defn-typed src/shop.clj:12: (order-total …) :qty 0 — should be at least 1
Not checked here: a value that is not data, a row schema that cannot be evaluated at compile time (in cljs, a schema with a symbol in it), a map that is not a literal. The value check needs malli: dev compiles load it; a Clojure release with the switch on checks values only if malli is already loaded. Key checks always run.
(malli.instrument/collect! {:ns …}) + (malli.instrument/instrument!). A bad
call then throws :malli.core/invalid-input / :malli.core/invalid-output. A plain
(require 'ns :reload) redefines the vars un-instrumented; re-collect + instrument after it.malli.dev.cljs/start! under a dev define; the release build
aliases it away (:build-options {:ns-aliases {malli.dev.cljs malli.dev.cljs-noop}}).
defmeta's registrations (cases, :meta, #'f) sit under goog.DEBUG, so a release build
drops them. The released bundle has no malli code and no cases.defn-typed's expansion contains no malli symbol (:malli/schema is a keyword in the attr-map):
nothing it emits loads malli. test/defn_typed/core_test.clj release-form asserts this.The hooks ship in resources/clj-kondo.exports/io.github.hyperfocusdisordered/defn-typed/; step 1 of
Static checking copies them to .clj-kondo/imports/io.github.hyperfocusdisordered/defn-typed/, which clj-kondo loads with no
further config (checked with clj-kondo v2026.01.19). The defn-typed hook lints the rows, the
arrow and the body as the def + defn above, with the row keys as locals, and reports the same
shape errors as the macro; the defmeta hook lints the map as code.
(check-var #'f) → {:var sym :cases n :failures [{:i :in :expected :actual}]}; a throwing
case → :actual [:thrown msg].(check-ns 'ns) → check-var over every function of ns that has examples.(deftests! 'ns) (clj) → one clojure.test test <fn>-inout per such function, so
clojure -M:test runs them with the rest of the suite.(tests #'f [[[args…] out] …]); defmeta pairs take one argument.malli.experimental/defnmx/defn annotates positional arguments Plumatic-style ([x :- :int, y :- :int]), supports
multi-arity, and expands to a defn followed by (malli.core/=> name schema) — a runtime call
into malli.core at load.defn-typed takes one map argument whose rows are the schema, binds every row as a local,
fills defaults from the rows, and stores the schema as :malli/schema metadata only (no malli
call in the expansion). The example pairs live beside it in defmeta. Single arity only.The library code calls malli.core/explain and malli.error/error-message
(resolved lazily by malli-fns), and relies on :malli/schema metadata being collected by
malli.instrument/collect! (clj) and on the :malli.core/invalid-input /
:malli.core/invalid-output / :malli.core/missing-key data. deps.edn declares 0.20.1.
Tested on 0.11.0 and 0.20.1; the oldest release both test suites pass on is 0.11.0:
::m/extra-key error retains the error value"; the suite fails
on 0.8.9);goog/mixin with Object.assign"; on 0.10.4 current
ClojureScript instrumentation fails with goog.mixin is not a function).A project that declares its own malli gets that version (tools.deps picks the top-level one).
clojure -M:test # clj
clojure -J-Ddefn-typed.inline=true -M:test # clj, switch on
clojure -M:cljs compile test && node out/node-tests.js # cljs (shadow-cljs :node-test)
clojure -M:cljs release inline && node out/inline-tests.js # cljs release, switch on
clj-kondo --lint src test # uses the exported hooks
TBD.
MIT — see LICENSE.
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 |