Typed functions for Clojure: one signature gives you static checks, runtime contracts, compile-time literal checks and example tests, and a call costs what a positional call costs.
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. defn-typed gives you that shape, and builds everything else from the one signature:
[in out] pairs above the function run as tests;defn (see Zero-cost calls).The schemas are malli schemas: malli does the validation, the error messages and the instrumentation. 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!). Without instrumentation
nothing is checked at run time, and cljs release builds contain no malli at all. Literal calls such
as (order-total {:discount 150}) are also checked at compile time — see Compile-time literal
checks. 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.1"}}}
;; deps.edn, from git
{:deps {io.github.hyperfocusdisordered/defn-typed {:git/tag "v0.2.0" :git/sha "779871e"}}}
;; shadow-cljs.edn
{:dependencies [[io.github.hyperfocusdisordered/defn-typed "0.2.1"]]}
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]
(declare name) ; the body may call name
(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 a
key beyond the rows, a key that is not a keyword literal ({k 1}) 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}, with a row read at call time, or
whose body recurs to the function (its recur takes the map).
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 and gets no literal check (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).The switch is for release builds only. A rewritten call skips <name>, so instrumentation does
not see it. With the switch on, redefining a function in the REPL can leave stale call sites: a
caller compiled earlier calls the new <name>--positional with the old row order (so does a
caller compiled against an older version of the function). Keep it off in dev, REPL and tests,
where 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).
In Clojure, with the switch on or off, a map-literal call is checked where it compiles: an unknown key (of a
closed map, ^{:closed true}; [:map …] is open), a missing required key (not judged when a key
is not a keyword literal, {k 1}), 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
ClojureScript: literal checks and the rewrite run in release (:advanced) builds; in dev,
clj-kondo and malli instrumentation cover the same calls. A :referred call from another
namespace is neither checked nor rewritten.
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 runs only where malli is already loaded: the dev REPL/test loaders load it first, so the files they (re)load get value checks, and a Clojure server compiling from source never loads it, switch on or off (the ClojureScript compiler loads it). 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 |