Typed defn for Clojure and ClojureScript: one signature gives Typed Clojure static checks, compile-time literal-call checks, clj-kondo editor types, malli runtime contracts and example tests — and a call costs a positional call.
(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)
)defmeta holds the doc and the [in out] example pairs; defn-typed takes one map of rows
{key schema} (malli schemas), the output schema after ->,
then the body, where every row key is a local with its default filled. A call names its arguments:
(order-total {:price 100 :qty 3}) ; => 300; with :inline on it compiles to (example/order-total--positional 100 3 0)What each layer reports, for wrong calls written in shop/cart.clj:
| Layer | Wrong call or case | Report |
|---|---|---|
| clj-kondo, as you type | (order-total {:price "100"}) |
error: Expected: integer, received: string. |
| compile | (order-total {:price 100 :qty 0}) |
WARNING defn-typed shop/cart.clj:4: (order-total …) :qty 0 — should be at least 1 |
| dev/test run | (order-total {:price 100 :qty n}), n = 0 |
throws defn-typed: example/order-total — invalid :qty: expected [:int {:min 1, :default 1}], got 0 |
| dev load | a case [{:price 100 :qty 3 :discount 10} 280] |
WARNING example.clj:3 example/order-total in/out case 2: expected 280, got 270 — input {:price 100, :qty 3, :discount 10} |
| Typed Clojure (optional) | a body that returns a string under -> :int |
output of order-total: Type mismatch: … |
The compile row judges keys always; values and ranges where malli is loaded when the call compiles (dev/test/REPL loaders, every ClojureScript build); the loader is in Install.
;; deps.edn, from Clojars
{:deps {io.github.hyperfocusdisordered/defn-typed {:mvn/version "0.3.2"}}
:aliases {:dev {:jvm-opts ["-Ddefn-typed.dev=true" "-Ddefn-typed.inline=false"]}
:test {:jvm-opts ["-Ddefn-typed.dev=true" "-Ddefn-typed.inline=false"]}}}
;; deps.edn, from git
{:deps {io.github.hyperfocusdisordered/defn-typed {:git/tag "v0.3.2" :git/sha "a01b52f"}}}
;; shadow-cljs.edn
{:dependencies [[io.github.hyperfocusdisordered/defn-typed "0.3.2"]]}Without -Ddefn-typed.dev=true nothing is checked at run time and no example runs, silently:
instrument! returns nil, check-ns returns []. That property turns on :dev, the Clojure
analogue of goog.DEBUG; -Ddefn-typed.inline=false keeps every call the map call, so
instrumentation sees it. Both are settings: a defn-typed.edn file at the project root holds
them for every build, and a -Ddefn-typed.<key> property wins over the file (see
Configuration). A loader namespace, on the dev/test classpath only, requires the
app and runs (malli.instrument/collect! {:ns …}) + (defn-typed.core/instrument!) (see
Checks run in dev/test only). metosin/malli comes along as a
dependency (see malli versions).
- Compile, every build: literal calls are checked (keys always; values and ranges where malli
is loaded), and with
:inlineon (Clojure by default, ClojureScript release builds) a fitting literal call compiles to the positional call. - Dev JVM (
:dev) or ClojureScript dev build (goog.DEBUG): malli instrumentation of every call once the loader ran, thedefmetacases as each definition loads, unknown keys of real maps, and Typed Clojure when it is on the classpath. - Release, at run time: nothing, except functions you mark
:malli-in-prod(see malli in production). The schemas stay as data (:malli/schemametadata,<name>-props); a ClojureScript release bundle has no malli code.
clj-kondo checks keys and types in the editor from emitted types (see Static checking).
The dev and test aliases set -Ddefn-typed.inline=false: there every call is the map call
through the var, so a plain map call always works, a redefinition reaches every caller, and no
caller goes stale. Calls compile positionally only with :inline on: release builds and servers,
which have no REPL. The literal check is a warning by default, not a gate: a failing literal
compiles to the map call. Where :inline stays on in a :dev JVM, a signature change prints
which namespaces to reload (:stale-callers :warn); a call typed at the REPL is never recorded.
A changed schema var that a row names (:order Order) prints that the function and its callers
use the old value.
Convert the functions that already take one options map first, one namespace at a time. Plain
defn stays beside them: multi-arity, variadic and positional functions (see Escape
hatch). Call sites do not change: a literal-map call is a map call, and a map
built elsewhere is passed as before.
Most languages give you one familiar shape for a typed function: name, inputs with their types, output type, body.
TypeScript function orderTotal(price: number, qty: number = 1, discount: number = 0): 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:
- static checks: clj-kondo flags wrong keys and types as you type (see Static checking);
- type checking (optional, clj): Typed Clojure checks bodies and call sites against the same
signature, including a body that returns a different type from its
->, with no hand-written annotations (see Typed Clojure); - compile-time literal checks: a literal call with a missing key or an out-of-range value warns during the build — keys always; values and ranges where malli is loaded when the call compiles (dev/test/REPL loaders, every ClojureScript build) (see Compile-time literal checks);
- runtime contracts: every call is checked in the REPL and in tests (with
:dev, see What runs where); - example tests: the
[in out]pairs above the function run as tests; - zero-cost calls: in release builds a literal-map call compiles to a positional call, as fast
as a plain
defn(see Zero-cost calls).
order-total at the top has the same shape, and its signature also says what none of the four
can: discount is 0 to 100. What the function does, then example inputs and outputs, then the
typed function: plain data, in that order, nothing else.
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: malli does the
validation, the error messages and the instrumentation (where they run: What runs
where). The example pairs run as tests (check-var, check-ns, deftests!).
Works in Clojure and ClojureScript (.clj, .cljs, .cljc).
clj-kondo reports wrong calls of defn-typed functions once their types are in 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: one command for any editor and CI (again after a signature change; see Editor types)
clojure -M -m defn-typed.kondo --src-dir src --out .clj-kondo/imports/defn-typed/types/config.edn
# 3. lint as usual
clj-kondo --lint srcWith the hooks alone clj-kondo checks the form's shape and the body, not the types of the calls.
(order-total {:price "100"}) ; error: Expected: integer, received: string.
(order-total {:qty 2}) ; error: Missing required key: :pricedefn-typed.kondo reads the source files and writes the clj-kondo config of every typed function
under the dirs: each defn-typed / defnt (its input read by the macro's own form-parts, a
defaulted row optional) and each plain defn carrying :malli/schema. It loads no namespace, so
.clj, .cljc and .cljs files are covered alike (a .cljs file through its :cljs reader
branches). The CLI of step 2 runs it: --src-dir repeats (--src-dir src --src-dir test), the
line OK 42 fns → .clj-kondo/imports/defn-typed/types/config.edn goes to stderr, stdout stays
empty.
clj-kondo loads a config under .clj-kondo/imports/ with no further setting; another path needs
:config-paths. In a schema, a symbol naming a top-level def of the same namespace (or of the
namespace it is :referred from) stands for that def's value, and [:fn …] for :any. The
output is canonical (every map sorted by key) and the file is written only when its text changes,
so the same sources leave it, and its git diff, alone; :written? false says so. malli's global
function-schema registry is not touched, so a REPL's own registrations stay as they are.
Optional speed-up: in a running REPL, (defn-typed.kondo/emit! {:src-dirs ["src" "test"] :out ".clj-kondo/imports/defn-typed/types/config.edn"}) writes the same file without a JVM start and
returns {:written? true :fns 42 :out …}. An after-edit hook can call it there before it lints
the edited file, and fall back to the CLI when no REPL runs:
# after-edit hook, $file under src/ edited; nrepl-eval = your nREPL client (fails when no REPL runs)
form='(defn-typed.kondo/emit! {:src-dirs ["src"] :out ".clj-kondo/imports/defn-typed/types/config.edn"})'
nrepl-eval "(do (require 'defn-typed.kondo) $form)" \
|| clojure -M -m defn-typed.kondo --src-dir src --out .clj-kondo/imports/defn-typed/types/config.edn
clj-kondo --lint "$file"An example after-edit hook that reloads, emits the types and lints: examples/claude-code.
What is checked where:
- Static (clj-kondo, once the types are emitted), at the call site:
- a value of the wrong type in a row:
(order-total {:price "100"}); - a missing required key:
(order-total {:qty 2}); - a wrong type inside a nested map row:
(ship {:addr {:zip "x"}}),ship's row:addr [:map [:zip :int]]; - a wrong type through a local:
(let [p "100"] (order-total {:price p})); - another typed function's output:
(order-total {:price (label {:n 1})}),labelbeing-> :string.
- a value of the wrong type in a row:
- Runtime (malli instrumentation, dev/test only, see
Checks run in dev/test only): the actual values, ranges
(
{:price 0}against[:int {:min 1}]) and the output. Unknown keys: literal calls flag them at compile time, real maps in dev builds (:unknown-keys); malli checks maps as open maps. clj-kondo's types carry no ranges and read every map as open. - Compile (the macro, at every map-literal call; cljs: every build, see Compile-time literal checks): unknown keys, missing keys, and values that are data against their row schema, ranges included — keys always; values and ranges where malli is loaded when the call compiles (dev/test/REPL loaders, every ClojureScript build).
- Release, at run time: nothing beyond the
:malli-in-prodfunctions (see What runs where).
Typed Clojure type-checks the code with the types
the signatures already declare: no annotation per function. It follows a value where clj-kondo
stops (a map built apart from the call) and checks the body against ->.
The library does not depend on Typed Clojure. Add it to your dev alias (with :dev on, every
definition is then checked as it loads, see :typed-check in Configuration):
:aliases {:dev {:extra-deps {org.typedclojure/typed.clj.checker {:mvn/version "1.3.0"}
org.typedclojure/typed.malli {:mvn/version "1.3.0"}}}}(require '[defn-typed.typed-clojure :refer [install! check-form!]])
(install! {:namespaces ['my.app.core]}) ; again after reloading a namespace
(check-form! {:ns 'my.app.core :form form :file "src/my/app/core.clj"})
;; => [{:file "src/my/app/core.clj" :line 12 :column 3 :defn-typed? true :kind nil
;; :message "Function inc could not be applied to arguments: …"}]install!collects the namespaces' malli function schemas, from which typed.malli's var-type provider (registered by thetypedclojure_config.cljcin its jar) types each function, and evaluatest/annfor each function's<name>--positional(where the body lives) and<name>-props, and for thedefn-typed.corefunctions an expansion calls. A function with type variables gets its ownt/anntoo,(t/All [T] …)(see Generic functions).- Loading
defn-typed.typed-clojureteaches typed.malli the[:tuple …]schema:[:tuple :int :string]is(t/HVec [t/AnyInteger t/Str])(typed.malli alone reads it ast/Any). check-form!checks one top-level form (read from the file with its:line) and returns every type error as data.:kindis:inputwhen a call of a defn-typed function does not fit its rows (the message startsinput of <f>:),:outputwhen a defn-typed body does not fit its->(output of <f>:), nil otherwise.:defn-typed?is true when the error is inside a(defn-typed …)form or its form calls a defn-typed function: a per-edit hook reports those first, while the rest of the code has no annotations yet.- It checks with
:check-form-eval :never. Typed Clojure's default,:after, evaluates every checked form again, which redefines the var without malli's instrumentation. - Every form the macro generates without your code in it carries
^:typed.clojure/ignore, so the checker sees your body and your calls, not the expansion.
What it finds, on seven of the 14 planted bugs of
test-typed/defn_typed/typed_bugs.clj (Typed Clojure reports
all 14; typed_clojure_test.clj asserts each one):
| Bug | Example | Typed Clojure | clj-kondo |
|---|---|---|---|
| wrong type into a row through a local | (let [id (label-of {:name "9"})] (cover-of {:lot_id id …})) |
yes | yes |
a :string result used as a number |
(inc (label-of {:name "a"})) |
yes | yes |
| wrong type in a nested map literal | (zip-of {:address {:zip "10115" :city "Berlin"}}) |
yes | yes |
| a field of another function's result into a row | (cover-of {:lot_id (:title m) …}), m = (summary-of {:id 9}) |
yes | yes |
| a field of a result used as a number | (inc (:title (summary-of {:id 9}))) |
yes | yes |
| a nested map built apart from the call | (let [addr {:zip (label-of …) :city "Berlin"}] (zip-of {:address addr})) |
yes | no |
the body returns another type than -> |
(defn-typed lot-count {:label :string} -> :int (str label)) |
yes | no |
| 7 / 7 | 5 / 7 |
clj-kondo here = the exported hooks plus the types of step 2 above (defn-typed.kondo); with the hooks alone it
finds 1 of 7 (a :string result used as a number).
Timing: the fixture's 33 top-level forms take about 330 ms together on a warm JVM (median 7 ms,
slowest 60 to 100 ms; check-form! per form, three passes after a warm-up, JVM 21). Check form by form, each with a deadline: over a very
large namespace, a whole-file check-ns may exhaust the checker's stack, and a checker thread that
runs away cannot be stopped, only abandoned (restart the JVM).
More examples, read top to bottom: the task, then its inputs and outputs, then the typed
function (order-total is at the top). 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 README's four marked blocks (order-total at the top, these two, and first-of under Generic functions) run as a test
(test/defn_typed/readme_test.clj evaluates them verbatim and asserts there are four).
(defmeta name {:doc "…" :inout-tests [[in out] …] …other var metadata})
(defn-typed name {key schema …} -> out-schema body…)
(defn-typed name [T …] {key schema …} -> out-schema body…) ; with type variables, see Generic functions
-
defmetagoes before thedefn-typedof the same name: the macro reads it to put:docon the function.defmetadeclaresname. -
defntisdefn-typedunder a short name:(defnt name {…} -> out body…)expands the same. -
Input = one map literal
{key schema …}. Keys are keywords; qualified keys bind by their name (:x/b→b). -
Defaults =
:defaultin 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) =:optionalwithout a default;:defaultthere is a compile error. -
Output = any malli schema after
->. -
Body: no argument vector — every row key is already a local.
-
:inout-tests=[in out]pairs,in= the function's single argument (the map; or the scalar of a one-argument plaindefn). A case passes iff(= out (f in)). A non-pair throws naming the var. As the definition loads, a failing case prints one line naming what differs: the key paths (maps are compared key by key, every other value as a whole), then the case's input:WARNING shop/order.clj:4 shop.order/place-order in/out case 0: [:sku] expected "bmx", got "sd" — input {:order {:sku "sd", :address {:city "Минск"}}}A key only in
outreads[:qty] expected 1, missing, a key only in the result[:gift] unexpected false; two differences are joined by;. A case that throws names the exception first:WARNING shop/order.clj:4 shop.order/place-order in/out case 1: threw Divide by zero (expected 0) — input {:n 0}assert-cases(and sodeftests!,test-var!,test-ns!) gives a failing case's assertion that same line as its message. -
A docstring, an attr-map, an argument vector, or
->with nothing after it insidedefn-typedis a compile error naming the function. Adefn-typedfunction takes one map argument by design: its named rows with defaults replace arities, and multi-arity, variadic or positional functions staydefn(see Escape hatch). -
A condition map
{:pre […] :post […]}first in the body works as infn, wherever the body lands (<name>--positional, or the function itself when its bodyrecurs to it or it has more than 20 rows). As infn,:postbeside arecurto the function is a compile error (Can only recur from tail position). -
A row or output schema malli cannot build is a compile error naming the function, the row, malli's error and the file:line; a schema held in a var is judged as the definition loads. clj judges once malli is loaded (a dev/test/REPL loader, the Typed Clojure bridge), cljs builds always. Names from a custom registry must be qualified (malli's own names are unqualified): they are trusted at expansion and checked when the registry is live.
defn-typed: shop.price/order-total — invalid schema for :qty: unknown schema :it (did you mean :int?) — :malli.core/invalid-schema {:schema :it} (src/shop/price.clj:10)
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 …
:arglists '([{:keys [key …]}])}
[m]
(let [{:keys [key …] :or {key default …}} m]
(name--positional key …))))so clj-kondo (with the exported hooks), malli's collect!/instrument! and any tool that reads
a defn see a defn. :arglists lists the rows, so (doc name)
and editor hovers show the inputs: ([{:keys [price qty discount]}]). The defaults are the :or
of the destructuring, taken from the rows when the macro expands. 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).
A function whose output type follows the type of its input lists type variables in a vector between its name and its input map:
(defmeta first-of
{:doc "Первый элемент списка — того же типа, что его элементы."
:inout-tests [[{:xs [3 4]} 3]
[{:xs []} nil]]})
(defn-typed first-of [T] {:xs [:sequential T]} -> [:maybe T]
(first xs)
)Each symbol of the vector is a type variable in every schema of the signature, the rows and the output, at any depth.
- malli has no type variables: the macro writes
[:any {:defn-typed/type-var "T"}]in T's place, so instrumentation,<name>-props, the compile-time literal checks and the clj-kondo types read:any. ClojureScript expands the same way (Typed Clojure is Clojure only). - Typed Clojure annotates the function and its
<name>--positionalwith(t/All [T] …), so a call's result has the type of what went in:(inc (or (first-of {:xs [1 2]}) 0))checks, and(inc (or (first-of {:xs ["a"]}) 0))does not. The same function with:anyin T's place returnst/Any:Function inc could not be applied to arguments: … Arguments: typed.clojure/Any. - T can sit inside any schema the Typed Clojure bridge converts:
[:sequential T],[:vector T],[:set T],[:map-of :keyword T](the values' type),[:maybe T],[:or T :int],[:map [:k T]],[:tuple T :int]((t/HVec [T t/AnyInteger])), and a schema a function builds:{:b (box T)}, with(defn box [t] [:map [:value t]]), is a type of your own over T. A registry schema ([:ref ::box]) is one fixed schema: a type variable cannot pass into it. - A type variable no schema uses, or a vector that is not distinct symbols, is a compile error
naming the function:
defn-typed f: no schema of the signature uses the type variable U.
A map literal at a call site is named-argument syntax: (order-total {:price p :qty 3}) names
the arguments for the compiler and builds no hash map. The body lives in <name>--positional,
whose parameters are the rows in entry order, and the compiler turns the literal call into the
positional call:
(order-total {:price p :qty 3})
;; compiles to
(example/order-total--positional p 3 0)
(order-total {:price (inc p) :qty 3})
;; compiles to (a value that is a call is bound first, in the literal's order)
(clojure.core/let [price__1658 (inc p) qty__1659 3] (example/order-total--positional price__1658 qty__1659 0))Release and production builds always do it. Dev and test builds keep the map call (<name>
destructures the map and calls <name>--positional), so instrumentation can check the map.
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 a row read at call time, or
whose body recurs to the function (its recur takes the map). A literal call compiled before
the function's definition (the name declared above it, by declare or by its defmeta; in
ClojureScript, a call above the definition in its file) is a map call too, and the definition
reports it under :literal-check:
defn-typed: src/shop/db.clj:812 calls shop.db/lot-vertical before its definition — the call compiles as a map, not positionally; move the definition above the call
The switch, the :inline setting:
- Clojure: on by default. Every direct call is covered,
:referred ones included (the function's:inline). The setting:inline false, in effect while the calling code compiles, keeps the map call; dev/test aliases set it as a system property (see Configuration). A literal call compiled to the positional call whilemalli.instrumentis loaded prints one stderr line per JVM:defn-typed: literal calls compile to positional calls, instrumentation will not check them — set -Ddefn-typed.inline=false in your dev/test alias. - ClojureScript: on in a release build (
:optimizations :advanced), off in dev, where the call stays the map call and is checked (see Compile-time literal checks). Calls through an alias (c/order-total), a:referred name, a qualified name, or inside the defining namespace are covered. In a release build such a call reaches the positional function directly:refer_inline_test.cljs, run in the:advancedinlinebuild, redefines the map function and the call does not see it.
A rewritten call skips <name>, so instrumentation does not see it: dev, REPL and test builds keep
the switch off, where every call goes through the var. With it on, a caller compiled earlier still
reaches a REPL redefinition of the body (it calls the <name>--positional var). A redefinition that
changes the rows makes those callers stale: they call the new <name>--positional with the old row
order. With :dev on, the redefinition prints the namespaces to reload (:stale-callers :warn,
the default):
defn-typed: shop.price/order-total changed its signature; callers compiled with the old one: shop.cart — reload them
:stale-callers :reload reloads them itself (see Configuration).
A 3-row function with 2 defaults, (total {:price p :qty 3}), criterium quick-bench on JVM 21,
each against a positional defn of the same body in the same JVM: switch on 11.6 ns (positional
12.4 ns), switch off 24.6 ns (positional 20.9 ns).
In Clojure, with the switch on or off, a map-literal call is checked where it compiles: an unknown key (any key
that is not a row), 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 map literal whose row is a
[:map …] (or [:maybe [:map …]]) is walked by the same rules, each finding led by its key path
(:order :price — missing required key); a value in it that is not data is skipped. By default a mismatch prints
one line to stderr and the call compiles to the map call; the build goes on:
WARNING defn-typed shop/cart.clj:12: (order-total …) :qty 0 — should be at least 1
With {:literal-check :error} in defn-typed.edn the mismatch is a compile error instead (see
Configuration).
ClojureScript: literal checks run in every build, dev watch included, and print the same
WARNING defn-typed <file>:<line>: … line; the rewrite to the positional call runs in release
(:advanced) builds only. In dev, clj-kondo and malli instrumentation cover the same calls too.
The dev check costs one recompile per fresh shadow-cljs JVM: its first build, dev or release,
recompiles each .cljs namespace holding defn-typed functions and each namespace that requires
one (shadow-cljs prints Failed reading cache for <ns>: failed to require macro-ns …), because
their cached analysis names a macro that exists only while the build runs. Measured on this
library's test build: about 0.55 s for the library's namespace, 5 to 9 ms per requiring namespace.
Builds in a running watch compile only what changed. {:literal-check :off} in defn-typed.edn
keeps a dev build free of the check and of that recompile.
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.
defn-typed.edn holds a project's settings. The compiler looks for it in its working directory,
then in each parent directory up to the filesystem root, and the first one found wins (as
clj-kondo finds .clj-kondo): one file at a monorepo's root serves clojure run in backend/ and
shadow-cljs run in miniapp/, and a nearer file overrides it. It is read once per JVM, at the
first macroexpansion; no file = the defaults.
{:dev false ; false (default) | true
:literal-check :warn ; :warn (default) | :error | :off
:unknown-keys :warn ; :warn (default) | :error | :off
:inline true ; true (default) | false
:stale-callers :warn ; :warn (default) | :reload | :off
:typed-check :warn ; :warn (default) | :error | :off
:typed-check-cache :on ; :on (default) | :off
:inout-check :warn ; :warn (default) | :error | :off
:report-success false} ; false (default) | true| Setting | Default | Where it applies | Read when | Per function (defmeta) |
|---|---|---|---|---|
:dev |
false |
Clojure (ClojureScript: goog.DEBUG) |
each definition's expansion | — |
:literal-check |
:warn |
Clojure, ClojureScript | each call site's compile | yes, for its call sites |
:unknown-keys |
:warn |
dev builds (:dev / goog.DEBUG) |
the definition's expansion (baked into the function) | yes |
:inline |
true |
Clojure (ClojureScript: release builds) | each call site's compile | — |
:stale-callers |
:warn |
Clojure, :dev on |
at a redefinition (run time) | — |
:typed-check |
:warn |
Clojure, :dev on, Typed Clojure on the classpath |
the definition's load | yes |
:typed-check-cache |
:on |
Clojure, :dev on, Typed Clojure on the classpath |
the definition's load | — |
:inout-check |
:warn |
dev builds | the definition's expansion | yes |
:report-success |
false |
Clojure, :dev on |
each file load (run time) | — |
The four per-function settings go into the function's defmeta ({:inout-check :off …}) and
win over the project's.
-
:dev: the Clojure analogue ofgoog.DEBUG, one switch for everything a definition does at load besides defining the function. Dev, test and REPL JVMs set it with-Ddefn-typed.dev=true(see the aliases below); production leaves it unset.What a release JVM (
:dev false) does when it loads a namespace: nothing beyond defining the functions. It runs no in/out cases, registers nodefmetacases, emits no unknown-key check on real-map calls, keeps no instrumentation upkeep, puts no:stale-callerswatch and records no signature, and runs no Typed Clojure check. Compile-time work stays: literal calls are checked (:literal-check) and rewritten to positional calls (:inline) in every build. ClojureScript has no:devsetting:goog.DEBUGis its signal, so a release build drops the same things and a dev build keeps them. -
:literal-check: a literal call that fails its compile-time check (see Compile-time literal checks) prints itsWARNING defn-typed …line and compiles to the map call (:warn), or is a compile error with the same text (:error): the namespace does not load, the build fails. Clojure and ClojureScript alike;:offin a ClojureScript dev build also skips the call-site macro (see Zero-cost calls).:offchecks nothing. -
:unknown-keys: a real map (not a literal) holding keys beyond the rows, in a dev build.:warnprintsdefn-typed: <ns/fn> unknown key :k — the keys are :a :bonce per function and set of unknown keys (up to 1000 such pairs per process, then one muted line);:errorthrows;:offemits no check. A release build has no check. -
:inline: whether a fitting literal call compiles to the positional call in Clojure (see Zero-cost calls). ClojureScript decides by the build: on in release, off in dev. -
:stale-callers(Clojure,:devon): what a redefinition does to the namespaces whose literal calls compiled against the old signature (a changed row order, or a changed schema var a row refers to).:warn(default) prints one line naming them;:reload(opt-in) reloads each caller's namespace from disk: top-level effects re-run, REPL-only edits in it are lost;:offdoes nothing. -
:typed-check(Clojure,:devon): with Typed Clojure on the classpath, each definition is type-checked as it loads (see Typed Clojure)::warnprints each finding,:errorfails the load,:offchecks nothing. A check runs up to 2 s per definition; the first one loads the checker (seconds). -
:typed-check-cache(Clojure,:devon)::onkeeps the outcome of each Typed Clojure check under the check's fingerprint, and a definition whose fingerprint has a kept outcome is not checked again: no finding counts as checked clean, findings print the sameWARNINGlines (or fail the load under:typed-check :error), a skip prints the sameskippedline. The fingerprint covers the definition's form, its schema, the schemas of the functions it calls (a callee's changed schema re-checks its callers), and the versions of Typed Clojure and defn-typed. Outcomes are kept on disk in.defn-typed/typed-check-outcomes.txtunder the JVM's working directory (add.defn-typed/to.gitignore); a load whose every definition has a kept outcome does not load Typed Clojure at all. It covers the type check only: which tests re-run is the test runner's business.:offneither reads nor writes the file. -
:inout-check(dev builds): thedefmetacases run as the definition loads::warnprints one line per failing case,:errorfails the load,:offruns nothing. -
:report-success: after a file loads (require,load-file, an editor's load-file), one line per namespace whose defn-typed checks all passed (schemas, in/out cases, Typed Clojure), on the stream theWARNINGlines use:defn-typed ✓ shop.order: 3 fns · 7 in/out cases · types oktypes okwhen Typed Clojure checked every function of the namespace with no finding, elsetypes skipped(Typed Clojure not on the classpath, a function's:typed-check :off, a check past its deadline). A namespace with a failure (aWARNINGline, a check that stopped the load) gets no line. Off by default: like malli's instrumentation and spec, a passing check prints nothing. Clojure only. The line comes within 25 ms after the load ends; a form evaluated at the REPL, outside a file load, gets none; a load stopped by an error outside defn-typed (a syntax error further down) still reports the definitions before it.
A JVM system property -Ddefn-typed.<key> wins over the file, the file over the default:
-Ddefn-typed.dev=true and -Ddefn-typed.report-success=true (any other value is off),
-Ddefn-typed.inline=false (any other value is on), -Ddefn-typed.literal-check,
-Ddefn-typed.unknown-keys, -Ddefn-typed.typed-check and -Ddefn-typed.inout-check =warn|error|off,
-Ddefn-typed.stale-callers=warn|reload|off, -Ddefn-typed.typed-check-cache=on|off.
ClojureScript reads the same file and properties in the compiler's JVM. An unknown key, or a value outside its list, is an error naming the key and
the allowed values.
Strict mode, a literal that fails the check does not compile:
;; defn-typed.edn
{:literal-check :error}Syntax error (ExceptionInfo) compiling order-total at (shop/cart.clj:12:3).
defn-typed shop/cart.clj:12: (order-total …) :qty 0 — should be at least 1
Dev and test turn the dev behaviour on and keep the map call, so instrumentation checks every call; their aliases set the properties (see Install), since the file is shared with production builds.
-
Clojure: a loader namespace on the test/REPL classpath only (never on the server's), in a JVM with
-Ddefn-typed.dev=true, requires the app and runs(malli.instrument/collect! {:ns …})(defn-typed.core/instrument!). A bad call then throws:malli.core/invalid-input/:malli.core/invalid-output(ex-data as malli builds it) with a message naming the function, the key, the expected schema and the value:
defn-typed: shop.price/order-total — invalid :qty: expected [:int {:min 1}], got 0defn-typed.core/instrument!takes malli'sinstrument!options (:filters,:report,:scope,:gen); a:reportof yours wins over that message. A definition loaded again is re-instrumented with the same options.(malli.instrument/instrument!)works too, with malli's message (:malli.core/invalid-input) and malli's default options after a redefinition. With:devoff,defn-typed.core/instrument!does nothing and returns nil. A private function is instrumented too: malli'scollect!reads public vars only, so in dev a private definition registers its own schema. A plain(require 'ns :reload)keeps the instrumentation of every defn-typed function it redefines. -
ClojureScript: the app calls
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 undergoog.DEBUG, so a release build drops them. The released bundle has no malli code and no cases, unless the app requiresdefn-typed.malli-in-prod. -
defn-typed's expansion contains no malli symbol (:malli/schemais a keyword in the attr-map): nothing it emits loads malli.test/defn_typed/core_test.cljrelease-formasserts this.
Every exception defn-typed throws is an ex-info whose (:type (ex-data e)) is one of:
:type |
thrown when |
|---|---|
:defn-typed.core/invalid-form |
a defn-typed / defmeta form is malformed (no name, a docstring, an argument vector, no ->, metadata on the input map, …) |
:defn-typed.core/invalid-schema |
a row or output schema malli cannot build |
:defn-typed.core/invalid-cases |
:inout-tests (or legacy tests) is not a vector of pairs |
:defn-typed.core/setting |
defn-typed.edn or a defn-typed.* system property holds an unknown key or a value outside its list |
:defn-typed.core/literal-call |
a map-literal call fails its compile-time check under :literal-check :error |
:defn-typed.core/forward-call |
a map-literal call compiled before its callee's definition, under :literal-check :error |
:defn-typed.core/unknown-keys |
a real map holds keys beyond the rows, under :unknown-keys :error |
:defn-typed.core/inout-failure |
an in/out case fails as the definition loads, under :inout-check :error |
:defn-typed.core/typed-check |
Typed Clojure reports a finding as the definition loads, under :typed-check :error |
A rejection by malli's instrumentation keeps malli's ex-data (:malli.core/invalid-input,
:malli.core/invalid-output); defn-typed.core/instrument! only rewrites its message.
To keep checking chosen functions in production, add :malli-in-prod to their defmeta. For
those functions this is malli instrumentation at run time, in every build. It is not static
typing: each call's input map and result are validated by malli while the program runs.
(defmeta place-bid
{:doc "Ставка на лот."
:inout-tests [...]
:malli-in-prod true}) ; or {:sample 0.01 :redact #{:phone :token}}
(defn-typed.malli-in-prod/on-malli-violation!
(fn [{:keys [fn direction value errors schema stack at repeats]}] ...))Require defn-typed.malli-in-prod once, at the app's entry point (clj and cljs). That brings
malli into the build and installs the checker. Without the require, an opted-in function runs
unchecked and prints one line: :malli-in-prod on <fn> but defn-typed.malli-in-prod is not loaded. A cljs release with no opted-in function and no require has no malli code, as before.
- What runs: the input validator and the output validator, compiled once per function. On
success nothing else runs. The functions without
:malli-in-prodare unchanged. - Guarantees:
- the function always returns its normal result;
- the check never throws into the caller;
- the handler runs off the call path: clj uses one background thread, cljs a 0 ms timeout;
- a handler that throws is caught and prints one stderr line, at most once per 60 s.
- The event handed to the handler:
:fn, the qualified symbol;:direction,:inputor:output;:value, the map or the result;:errors, as[{:path :value :message} …], where:messageis malli's humanized text;:schema, the schema's form;:stack, the top frames as text, built only on a violation;:at, epoch ms;:repeats.
- Redact: the keys in
:redactare removed from:valueat any depth before the handler sees it. An error whose path goes through such a key carries no:value. - Dedupe: a function reports one event per set of failing paths per 60 s. The violations
inside that window are counted into the next event's
:repeats. No handler registered: each event prints one stderr line, with paths and messages and no values. - Sample:
:sample 0.01checks about 1 % of calls, and the other calls run with no check. The draw isdefn-typed.malli-in-prod/*random*. - Zero-cost calls: the switch never rewrites an opted-in function's call to the positional call, because every call has to pass through the check.
- Opted-in functions are
defn-typedonly. A dev loader's instrumentation (which throws) still wraps the same function.
<name>-props is a plain malli schema. Validate a form or API input with it directly:
(me/humanize (m/explain order-total-props {:price 100 :qty 0})) ; m = malli.core, me = malli.error
;; => {:qty ["should be at least 1"]}Use it for forms and API input. It is the same schema the function's contract uses, and it works in clj and in a cljs release build.
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 and the two defns above, with the row keys as locals, so a direct
name--positional call resolves and its arity is checked, and reports the same shape errors as the
macro; a row whose name appears nowhere in the body is clj-kondo's unused binding at its key (a
nested fn that re-destructures the props, as a Reagent form-2 or create-class render fn does,
counts as reading the row); the defn it lints
carries the :doc of the defmeta above, in clj-kondo's analysis, which clojure-lsp's hover reads.
The defmeta hook lints the map as code. The exported config excludes name-props and
name--positional from clojure-lsp's unused-public-var (:exclude-regex).
They need :dev on (-Ddefn-typed.dev=true, see Install); with it off no case is
registered, so check-ns returns [] and deftests! defines no test.
(check-var #'f)→{:var sym :cases n :failures [{:i :in :expected :actual}]}; a throwing case →:actual [:thrown msg].(check-ns 'ns)→check-varover every function ofnsthat has examples.(deftests! 'ns)(clj) → oneclojure.testtest<ns>--<fn>-inoutper such function, in the namespace that calls it, soclojure -M:testruns them with the rest of the suite; the ns in the name keeps two namespaces' functions of one name as two tests.- A function with several positional arguments registers its examples with
(tests #'f [[[args…] out] …]);defmetapairs take one argument.
A plain defn sits beside defn-typed functions: multi-arity, variadic or positional functions
stay defn, with a hand-written schema and their cases still in defmeta above (cases of a
one-argument function are [in out] pairs; several positional arguments register through tests):
(defmeta clamp {:doc "n held between lo and hi."})
(defn clamp
{:malli/schema [:=> [:cat :int :int :int] :int]}
[n lo hi]
(max lo (min hi n)))
(defn-typed.core/tests #'clamp [[[5 0 3] 3] [[-1 0 3] 0]])To move a function off the library, write the defn its expansion shows (see Syntax): delete the
input table and ->, keep the destructuring.
(defn order-total [{:keys [price qty discount] :or {qty 1 discount 0}}]
(quot (* price qty (- 100 discount)) 100))Its call sites need no change: a literal-map call is a map call.
mx/defnannotates positional arguments Plumatic-style ([x :- :int, y :- :int]), supports multi-arity, and expands to adefnfollowed by(malli.core/=> name schema)— a runtime call intomalli.coreat load.defn-typedtakes 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/schemametadata only (no malli call in the expansion). The example pairs live beside it indefmeta. One arity by design: named rows with defaults replace arities, and multi-arity, variadic or positional functions staydefn.
deps.edn declares 0.20.1. The oldest release every test suite passes on is 0.16.3
(clojure -M:test with the switch on and off, the cljs test and inline builds). On 0.16.0 to
0.16.2 instrumenting a function again stacks a second wrapper (one clj failure); on 0.13.0 the clj
suite has 9 failures (a reload drops the instrumentation; the invalid-schema, literal-check and
clj-kondo type tests fail too). The cljs suite passes on both.
A project that declares its own malli gets that version (tools.deps picks the top-level one).
clojure -M:test # clj, switch on (the default)
clojure -J-Ddefn-typed.inline=false -M:test # clj, switch off
clojure -M:cljs compile test # cljs (shadow-cljs :node-test, runs the tests itself)
clojure -M:cljs release inline && node out/inline-tests.js # cljs release, switch on
clojure -M:typed # Typed Clojure bridge (test-typed/)
clj-kondo --lint src test # uses the exported hooksTBD.
MIT — see LICENSE.