Liking cljdoc? Tell your friends :D

defn-typed

test Clojars Project cljdoc badge

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.

Why

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:

  • static checks: clj-kondo flags wrong keys and types as you type (see Static checking);
  • compile-time literal checks: a literal call with a missing key or an out-of-range value warns during the build (see Compile-time literal checks);
  • runtime contracts: every call is checked in the REPL and in tests;
  • 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).

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.

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

Install

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

Static checking

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:

  • Static (clj-kondo, after step 2), 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}]), unknown keys of a closed map (^{:closed true}), and the output. clj-kondo's types carry no ranges and read every map as open.
  • Compile (the macro, at every map-literal call; cljs: release builds, see Compile-time literal checks): unknown keys of a closed map, missing keys, and values that are data against their row schema, ranges included.
  • Release: nothing. The types live in .clj-kondo, instrumentation only in dev/test.

Using Claude Code? examples/claude-code gives the agent this check after every edit.

Example

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

Syntax

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

Zero-cost calls

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:

  • Clojure: the JVM system property 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).
  • ClojureScript: on in a release build (: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).

Compile-time literal checks

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.

Checks run in dev/test only

  • Clojure: a loader namespace on the test/REPL classpath only (never on the server's) requires the app and runs (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.
  • 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.
  • 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.

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

Running the examples

  • (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.
  • A function with several positional arguments registers its examples with (tests #'f [[[args…] out] …]); defmeta pairs take one argument.

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. Single arity only.

malli versions

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:

  • clj: needs 0.9.0 (changelog: "::m/extra-key error retains the error value"; the suite fails on 0.8.9);
  • cljs: needs 0.11.0 (changelog: "Replace 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).

Tests

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

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