Liking cljdoc? Tell your friends :D

defn-typed

test Clojars Project cljdoc badge

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:

LayerWrong call or caseReport
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 = 0throws defn-typed: example/order-total — invalid :qty: expected [:int {:min 1, :default 1}], got 0
dev loada 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 -> :intoutput 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.

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).

What runs where

  • Compile, every build: literal calls are checked (keys always; values and ranges where malli is loaded), and with :inline on (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, the defmeta cases 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/schema metadata, <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).

REPL

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.

Adopting in an existing codebase

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.

Why

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.

Forms

  • 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).

Static checking

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 src

With 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: :price

Editor types (clj-kondo)

defn-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})}), label being -> :string.
  • 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-prod functions (see What runs where).

Typed Clojure

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 the typedclojure_config.cljc in its jar) types each function, and evaluates t/ann for each function's <name>--positional (where the body lives) and <name>-props, and for the defn-typed.core functions an expansion calls. A function with type variables gets its own t/ann too, (t/All [T] …) (see Generic functions).
  • Loading defn-typed.typed-clojure teaches typed.malli the [:tuple …] schema: [:tuple :int :string] is (t/HVec [t/AnyInteger t/Str]) (typed.malli alone reads it as t/Any).
  • check-form! checks one top-level form (read from the file with its :line) and returns every type error as data. :kind is :input when a call of a defn-typed function does not fit its rows (the message starts input of <f>:), :output when 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):

BugExampleTyped Clojureclj-kondo
wrong type into a row through a local(let [id (label-of {:name "9"})] (cover-of {:lot_id id …}))yesyes
a :string result used as a number(inc (label-of {:name "a"}))yesyes
wrong type in a nested map literal(zip-of {:address {:zip "10115" :city "Berlin"}})yesyes
a field of another function's result into a row(cover-of {:lot_id (:title m) …}), m = (summary-of {:id 9})yesyes
a field of a result used as a number(inc (:title (summary-of {:id 9})))yesyes
a nested map built apart from the call(let [addr {:zip (label-of …) :city "Berlin"}] (zip-of {:address addr}))yesno
the body returns another type than ->(defn-typed lot-count {:label :string} -> :int (str label))yesno
7 / 75 / 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).

Example

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).

Syntax

(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
  • defmeta goes before the defn-typed of the same name: the macro reads it to put :doc on the function. defmeta declares name.

  • defnt is defn-typed under 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 = :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.

  • :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. 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 out reads [: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 so deftests!, 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 inside defn-typed is a compile error naming the function. A defn-typed function takes one map argument by design: its named rows with defaults replace arities, and multi-arity, variadic or positional functions stay defn (see Escape hatch).

  • A condition map {:pre […] :post […]} first in the body works as in fn, wherever the body lands (<name>--positional, or the function itself when its body recurs to it or it has more than 20 rows). As in fn, :post beside a recur to 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).

Generic functions

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>--positional with (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 :any in T's place returns t/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.

Zero-cost calls

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 while malli.instrument is 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 :advanced inline build, 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).

Compile-time literal checks

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.

Configuration

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
SettingDefaultWhere it appliesRead whenPer function (defmeta)
:devfalseClojure (ClojureScript: goog.DEBUG)each definition's expansion—
:literal-check:warnClojure, ClojureScripteach call site's compileyes, for its call sites
:unknown-keys:warndev builds (:dev / goog.DEBUG)the definition's expansion (baked into the function)yes
:inlinetrueClojure (ClojureScript: release builds)each call site's compile—
:stale-callers:warnClojure, :dev onat a redefinition (run time)—
:typed-check:warnClojure, :dev on, Typed Clojure on the classpaththe definition's loadyes
:typed-check-cache:onClojure, :dev on, Typed Clojure on the classpaththe definition's load—
:inout-check:warndev buildsthe definition's expansionyes
:report-successfalseClojure, :dev oneach 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 of goog.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 no defmeta cases, emits no unknown-key check on real-map calls, keeps no instrumentation upkeep, puts no :stale-callers watch 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 :dev setting: goog.DEBUG is 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 its WARNING 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; :off in a ClojureScript dev build also skips the call-site macro (see Zero-cost calls). :off checks nothing.

  • :unknown-keys: a real map (not a literal) holding keys beyond the rows, in a dev build. :warn prints defn-typed: <ns/fn> unknown key :k — the keys are :a :b once per function and set of unknown keys (up to 1000 such pairs per process, then one muted line); :error throws; :off emits 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, :dev on): 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; :off does nothing.

  • :typed-check (Clojure, :dev on): with Typed Clojure on the classpath, each definition is type-checked as it loads (see Typed Clojure): :warn prints each finding, :error fails the load, :off checks nothing. A check runs up to 2 s per definition; the first one loads the checker (seconds).

  • :typed-check-cache (Clojure, :dev on): :on keeps 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 same WARNING lines (or fail the load under :typed-check :error), a skip prints the same skipped line. 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.txt under 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. :off neither reads nor writes the file.

  • :inout-check (dev builds): the defmeta cases run as the definition loads: :warn prints one line per failing case, :error fails the load, :off runs 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 the WARNING lines use:

    defn-typed ✓ shop.order: 3 fns · 7 in/out cases · types ok
    

    types ok when Typed Clojure checked every function of the namespace with no finding, else types skipped (Typed Clojure not on the classpath, a function's :typed-check :off, a check past its deadline). A namespace with a failure (a WARNING line, 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.

Checks run in dev/test only

  • 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 0
    

    defn-typed.core/instrument! takes malli's instrument! options (:filters, :report, :scope, :gen); a :report of 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 :dev off, defn-typed.core/instrument! does nothing and returns nil. A private function is instrumented too: malli's collect! 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 under goog.DEBUG, so a release build drops them. The released bundle has no malli code and no cases, unless the app requires defn-typed.malli-in-prod.

  • 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.

Errors

Every exception defn-typed throws is an ex-info whose (:type (ex-data e)) is one of:

:typethrown when
:defn-typed.core/invalid-forma defn-typed / defmeta form is malformed (no name, a docstring, an argument vector, no ->, metadata on the input map, …)
:defn-typed.core/invalid-schemaa 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/settingdefn-typed.edn or a defn-typed.* system property holds an unknown key or a value outside its list
:defn-typed.core/literal-calla map-literal call fails its compile-time check under :literal-check :error
:defn-typed.core/forward-calla map-literal call compiled before its callee's definition, under :literal-check :error
:defn-typed.core/unknown-keysa real map holds keys beyond the rows, under :unknown-keys :error
:defn-typed.core/inout-failurean in/out case fails as the definition loads, under :inout-check :error
:defn-typed.core/typed-checkTyped 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.

malli in production

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-prod are 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, :input or :output;
    • :value, the map or the result;
    • :errors, as [{:path :value :message} …], where :message is 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 :redact are removed from :value at 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.01 checks about 1 % of calls, and the other calls run with no check. The draw is defn-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-typed only. A dev loader's instrumentation (which throws) still wraps the same function.

Validating data with the same schema

<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.

clj-kondo

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).

Running the examples

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-var over every function of ns that has examples.
  • (deftests! 'ns) (clj) → one clojure.test test <ns>--<fn>-inout per such function, in the namespace that calls it, so clojure -M:test runs 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] …]); defmeta pairs take one argument.

Escape hatch

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.

Compared with malli.experimental/defn

  • mx/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. One arity by design: named rows with defaults replace arities, and multi-arity, variadic or positional functions stay defn.

malli versions

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).

Tests

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 hooks

Contributing / upstream

TBD.

License

MIT — see LICENSE.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close