Skeptic is a Leiningen plugin that statically type-checks Clojure projects based on Plumatic Schema annotations.
Experimental support for Malli is in development.
Call sites where inferred argument types do not fit the declared input schema:
(s/defn inc-int :- s/Int
[x :- s/Int]
(+ x 1))
(inc-int "1") ; String flows into an s/Int parameter
Return values where the inferred result does not fit the declared output schema:
(s/defn as-int :- s/Int
[x :- s/Int]
(str x)) ; body returns String, not s/Int
Nilability and structural mismatches that flow into declared schemas:
(s/defn inc-int :- s/Int
[x :- s/Int]
(+ x 1))
(s/defn caller
[m :- {:n (s/maybe s/Int)}]
(inc-int (:n m))) ; (:n m) is (maybe s/Int); nil case flows into s/Int
Add the plugin to the :plugins vector in your project.clj:
:plugins [[org.clojars.nomicflux/lein-skeptic "0.8.1"]]
Or for the snapshot version:
:plugins [[org.clojars.nomicflux/lein-skeptic "0.8.2-SNAPSHOT"]]
Add an alias to your deps.edn:
{:aliases
{:skeptic
{:extra-deps
{org.clojars.nomicflux/skeptic {:mvn/version "0.8.2-SNAPSHOT"
:exclusions [org.clojure/tools.deps]}
;; Runtime deps Skeptic was built against, forced at depth 1 in the
;; consumer's resolution so transitive nearest-wins cannot drop or
;; downgrade them. Mirrors lein-skeptic's `skeptic-profile`, which
;; also excludes `org.clojure/tools.deps` from skeptic and re-adds
;; it at depth 1 here.
org.clojure/clojurescript {:mvn/version "1.11.132"}
org.clojure/data.json {:mvn/version "2.5.1"}
org.clojure/tools.analyzer {:mvn/version "1.2.2"}
org.clojure/tools.analyzer.jvm {:mvn/version "1.4.0-beta1"}
org.clojure/tools.cli {:mvn/version "1.0.214"}
org.clojure/tools.deps {:mvn/version "0.29.1598"}
org.babashka/sci {:mvn/version "0.12.51"}
prismatic/schema {:mvn/version "1.4.1"}
prismatic/plumbing {:mvn/version "0.6.0"}
metosin/malli {:mvn/version "0.20.1"}
commons-io/commons-io {:mvn/version "2.11.0"}}
:main-opts ["-m" "skeptic.cli.main"]}}}
clj -T (tool installation) is not supported: Skeptic's analyzer
must load the project's namespaces, and -T strips the project's
:deps and :paths from the classpath.
From the project you want to check.
With Leiningen:
lein skeptic
With the Clojure CLI:
clojure -M:skeptic
Both invocations exit with status 0 when no inconsistencies are
found and status 1 when they report inconsistencies.
Options:
-n, --namespace NAMESPACE: only check the specified namespace.
Repeatable, and accepts comma-separated values: -n a.ns -n b.ns and
-n a.ns,b.ns are equivalent.-c, --show-context: print local-variable and reference context for each
result.-v, --verbose: print extra progress and debugging output.-a, --analyzer: print the analyzer forms for the namespace being checked.-k, --keep-empty: include analyzed expressions even when they produced no
mismatches.--explain-full: show fully expanded structural forms in type-mismatch
output instead of compact declared names.-p, --porcelain: emit machine-readable JSONL (one JSON object per line)
instead of the default human-readable output. See Output below.--plumatic-disable: skip Plumatic Schema intake entirely. No s/defn /
s/def / s/defschema declarations are admitted, no :skeptic/type-overrides
are applied, and no finding will report [source: schema] /
"source": "type-override". See Disabling an intake stream.--malli-disable: skip Malli intake entirely. No m/=>, mx/defn, or
:malli/schema Var-meta declarations are admitted, and no finding will report
[source: malli]. See Disabling an intake stream.--profile: profile the run (CPU, memory, wall-clock time). Long-only.-o, --output OUTPUT_FILE: write Skeptic's output to this file instead of
stdout, so lein/JVM messages stay on stdout. Works with text and -p JSONL
output.--paths PATHS (deps.edn only): comma-separated source paths to check,
overriding the paths discovered from deps.edn.--alias ALIAS (deps.edn only): a deps.edn alias to merge when
discovering source paths (repeatable; e.g. --alias :test).Skeptic admits typed declarations from two independent stream sources —
Plumatic Schema (s/defn / s/def / s/defschema) and Malli (m/=>,
mx/defn, :malli/schema Var-meta) — plus Skeptic's built-in native-fn
registry. --plumatic-disable and --malli-disable switch off either stream
wholesale.
When a stream is disabled:
source: attribution.s/defn + m/=> on the same
symbol) is still admitted via the surviving stream.--plumatic-disable also disables :skeptic/type-overrides from
.skeptic/config.edn, since overrides are a Plumatic-domain construct.Both flags can be combined; the result is a run that checks only against Skeptic's built-in native-fn declarations.
The default output is a human-readable, ANSI-coloured report, one block per inconsistency, grouped by namespace. Findings include the source of the reported type, such as Schema, Malli, a built-in/native declaration, a type override, or inference.
---------
Namespace: skeptic.showcase
Location: /Users/demouser/Code/skeptic/skeptic/src/skeptic/showcase.clj:10:3 [source: native]
Blame: context( value )
---
(str x)
has an output mismatch against the declared return type.
Declared return type expects:
Int
Problem fields:
- Str but expected Int
---------
Namespace: skeptic.showcase
Location: /Users/demouser/Code/skeptic/skeptic/src/skeptic/showcase.clj:14:3 [source: schema]
Blame: context( value )
---
(:n m)
in
(inc-int (:n m))
has inferred type incompatible with the expected type:
Problems:
- a nullable value was provided where the type requires a non-null value
Per-namespace inconsistencies:
skeptic.showcase: 2
When the run finds inconsistencies, the report ends with a per-namespace summary listing each affected namespace and its error count, sorted with the worst-offending namespace first.
-p / --porcelain)lein skeptic -p switches stdout to newline-delimited JSON. One object per
line, in this order:
{"kind": "ns-discovery-warning", "path": "src/foo/broken.clj", "message": "..."}
{
"kind": "finding",
"ns": "foo.bar",
"report_kind": "input",
"location": {
"file": "src/foo/bar.clj",
"line": 42,
"column": 3,
"source": "schema",
"lang": "clj"
},
"blame": "(+ 1 :x)",
"blame_side": "term",
"blame_polarity": "positive",
"rule": "ground-mismatch",
"actual_type": {"t": "ground", "name": "Keyword"},
"expected_type": {"t": "ground", "name": "Int"},
"actual_type_str": "Keyword",
"expected_type_str": "Int",
"focuses": ["x"],
"enclosing_form": "(defn f [x] (+ 1 x))",
"expanded_expression": "(clojure.core/+ 1 x)",
"messages": ["Keyword is not compatible with Int at ..."]
}
{
"kind": "exception",
"ns": "foo.bar",
"phase": "declaration",
"location": {
"file": "src/foo/bar.clj",
"line": 99,
"source": "schema",
"lang": "clj"
},
"blame": "my-fn",
"exception_class": "java.lang.RuntimeException",
"exception_message": "could not resolve schema",
"messages": ["Skeptic hit an exception while checking declared schema for my-fn ..."]
}
{
"kind": "namespace-error-summary",
"counts": {
"foo.bar": 5,
"foo.baz": 2
}
}
{
"kind": "run-summary",
"errored": true,
"finding_count": 7,
"exception_count": 1,
"namespace_count": 12,
"namespaces_with_findings": 3
}
Exit code matches text mode (0 clean, 1 otherwise).
See docs/jsonl-output.md for the full per-kind field
spec and the structured type-tag reference.
Skeptic reads optional project-level configuration from .skeptic/config.edn
at the project root. The file is EDN and every key is optional.
{:exclude-files ["src/fixtures/*.clj"
"test/**/*_examples.clj"]
:type-overrides {clojure.tools.logging/infof {:output (s/eq nil)}}}
:exclude-filesVector of glob patterns matched against each file's path relative to the
project root. Matched files are skipped entirely. Patterns use the platform's
java.nio.file.PathMatcher glob syntax (*, **, ?, character classes).
:type-overridesMap from fully-qualified symbol to an override map with any of :schema,
:output, :arglists. Values are Plumatic Schema expressions evaluated with
[schema.core :as s] in scope, so you can write (s/eq nil), s/Int, etc.
Overrides replace whatever Skeptic would otherwise infer or collect for that
symbol at call sites.
{:type-overrides {clojure.tools.logging/infof {:output (s/eq nil)}}}
After this, call sites of infof are checked as returning nil.
Skeptic loads and admits ClojureScript source files alongside Clojure.
It discovers .cljs and .cljc files via project-layout-aware helpers
for deps.edn, Leiningen, and Shadow-CLJS.
Each cljs source file is parsed with the public
cljs.analyzer.api/parse-ns (which loads any :require-macros
namespaces on the JVM) and analyzed form-by-form with the 3-arity
analyze. No compiler state is constructed or carried across calls —
every form analysis rebuilds a fresh ephemeral environment from the
source file's ns AST.
Both intake streams support cljs:
s/defn, s/def, and s/defschema declarations
on cljs vars are admitted. Post-macroexpansion schema bodies are
evaluated in a sci-sandboxed Clojure interpreter configured with
schema.core pre-loaded, so s/Int, s/maybe, s/eq, and the rest
resolve to the same Plumatic Schema record values they would on the
JVM without using clojure.lang.Compiler/eval.:malli/schema var-meta on cljs vars is read directly off
the cljs analyzer AST and passes through the same
malli-spec->type bridge used for JVM admissions..cljc files are admitted twice — once with :clj reader-conditional
features active and once with :cljs — and identical findings from both
passes are deduped at the JSONL layer with lang set to
["clj","cljs"]. Findings unique to one pass keep lang as a single
keyword string ("clj" or "cljs").
Pass --cljs-disable to skip cljs admission entirely. With the flag
set, .cljs files are dropped and .cljc files are admitted as
:clj-only — the :cljs reader-conditional branch is discarded.
Known limitation: a pure-cljs schema that references another pure-cljs namespace requires the dependency to be analyzable in dependency order.
Skeptic reads Malli function declarations from :malli/schema Var metadata
and from the compile-time (malli.core/function-schemas) registry (which
captures m/=> and malli.experimental/defn declarations):
(defn takes-int
{:malli/schema [:=> [:cat :int] :string]}
[x]
(str x))
Currently parsed forms include single-arity [:=> [:cat ...] out] and
multi-arity [:function [:=> ...] [:=> ...]]; primitive leaves such as
:int, :string, :keyword, :symbol, :boolean, :double, :float,
:nil, :qualified-keyword, :qualified-symbol, and :any; plus
:maybe, :or, :and, :tuple, :vector, :set, :sequential (with
optional :min/:max properties parsed and dropped — Skeptic does not
constrain container length), :map (with required keys,
{:optional true} keys, and nested values; :closed true is honored,
and the default is open — extra keyword keys are admitted with Any
values, mirroring Malli's open-by-default semantics), :multi with
{:dispatch :kw} tagged-dispatch (later branches are correctly narrowed
by negation of earlier tags; fn-dispatch is admitted but each arm stands
alone), :=, :enum, :schema (with optional {:registry {...}}
properties carrying a local registry), :ref (resolved through the
active registry, with cycle detection: a recursive position emits an
InfCycleT rather than diverging), and bare predicate symbols that
Skeptic recognizes.
Broader Malli forms are still experimental. Sequence/regex combinators
outside the :=> head (e.g. :cat outside the function head, :alt,
:*, :+, :?, :repeat, :re, :fn) are admitted when Malli
accepts them; their Skeptic type is currently dynamic.
Skeptic provides three opt-out mechanisms for when its inference is wrong or too dynamic.
Use :skeptic/ignore-body in a function's attribute map to skip checks inside
the function body. The declared schema still applies to all callers:
(s/defn my-fn :- s/Int
{:skeptic/ignore-body true}
[x :- s/Int]
(int-add nil x))
The body's internal mismatch (passing nil to int-add) is suppressed.
Callers are still checked against the declared :- s/Int schema.
Use :skeptic/opaque in a function's attribute map to exclude both the
function body and its schema from checking. Callers see the function as
accepting and returning s/Any:
(s/defn my-fn :- s/Int
{:skeptic/opaque true}
[x :- s/Int]
"not-an-int")
The body is not checked, and callers can pass any type and expect any type back.
Use ^{:skeptic/type T} metadata on an expression to pin its inferred type to
schema T:
(let [y ^{:skeptic/type s/Int} (some-call-that-returns-any)]
(int-add y 1))
The expression's type is treated as s/Int for subsequent checks. Clojure
does not allow metadata on bare literal values (numbers, strings, keywords);
wrap them in a form if needed:
^{:skeptic/type s/Int} (identity 42)
Skeptic discovers .clj, .cljs, and .cljc source files under your
project's source paths, loads each namespace (via JVM require for
Clojure and via the public cljs.analyzer.api for ClojureScript),
collects the Plumatic Schema and Malli annotations you've written on
vars and functions, infers a type for each expression in your code, and
compares the inferred types against the declared schemas on function
inputs and outputs. Each mismatch is reported with a source location,
the inferred type, and the expected type. .cljc files are admitted
twice — once with the :clj reader-conditional feature active and once
with :cljs — and identical findings from both passes are deduped with
their lang set to ["clj","cljs"].
If a discovered file cannot be loaded — for example, a .clj file
whose body depends on a runtime not available to the lein/JVM process,
or a .cljs file whose require graph cannot be parsed — Skeptic skips
it cleanly with a discovery warning naming the file and the underlying
load error. The rest of the project continues to be checked. Discovery
warnings do not flip the run's exit code on their own.
During lein skeptic, the checker runs with Plumatic Schema function
validation disabled around the analysis pass. Projects that enable runtime
Schema validation still keep that behavior for their own code; Skeptic simply
avoids paying that validation cost while it is inspecting the project.
Skeptic's cast semantics — how directional checks between inferred and declared types assign responsibility for a mismatch — are informed by the polymorphic blame calculus and runtime cast algorithm from:
Amal Ahmed, Robert Bruce Findler, Jeremy G. Siek, and Philip Wadler. Blame for All. In Proceedings of the 38th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), Austin, TX, USA, January 2011. ACM. https://doi.org/10.1145/1926385.1926409
The key takeaway is that Any is not treated as some sort of Top or Object type. Any (and internally, Dyn) is
treated as a Dynamic type that should be treated as typechecking anything, until a more precise form can be inferred.
This is what lets Skeptic work with dynamic code without requiring users to add in a library of type hints: proveable
inconsistencies are flagged without required proof of consistency.
To run an unreleased version of the plugin from a local checkout:
script/install-local.sh we delete old versions in your .m2 cache, install the Skeptic library, then
install the lein-skeptic plugin. This will ensure a clean run, but careful if you are running multiple versions.lein install in the skeptic/ directory first to install the core library, then again in the
lein-skeptic/ directory to install the plugin.The MIT License (MIT)
Can you improve this documentation? These fine people already did:
Michael A., Michael Anderson & github-actions[bot]Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |