Skeptic statically type-checks Clojure and ClojureScript projects based on Plumatic Schema and Malli annotations. It runs as a Leiningen plugin or as a Clojure CLI tool.
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.9.0-rc9"]]
Add a tool alias to your deps.edn:
{:aliases
{:skeptic
{:deps {org.clojars.nomicflux/skeptic {:mvn/version "0.9.0-rc9"}}
:ns-default skeptic.tool}}}
Then run it from the project you want to check:
clj -T:skeptic check
From the project you want to check.
With Leiningen:
lein skeptic
With the Clojure CLI:
clj -T:skeptic check
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.--malli-disable: skip Malli intake.--cljs-enable: turn on ClojureScript analysis (experimental; off by
default).--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. Works with text and -p JSONL output.:project-dir PATH (deps.edn tool only): absolute path to the project to
check, for running the tool from outside the project root. Example:
clj -T:skeptic check :project-dir '"/path/to/project"'.:paths PATHS (deps.edn tool only): string or vector of source paths to
check, overriding the paths discovered from deps.edn. Example:
clj -T:skeptic check :paths '["src"]'.:alias ALIAS (deps.edn tool only): keyword, string, or vector of aliases
to merge when discovering source paths. Example:
clj -T:skeptic check :alias :test.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.
ClojureScript support is experimental and off by default: .cljs files
are skipped and .cljc files are admitted as :clj-only — the :cljs
reader-conditional branch is discarded.
Pass --cljs-enable (Leiningen) or :cljs-enable true (deps.edn tool)
to load and admit ClojureScript source files alongside Clojure. .cljc files are
admitted twice — once with :clj reader-conditional features active and once with :cljs.
Skeptic reads Malli function declarations from :malli/schema Var metadata
and from the compile-time (malli.core/function-schemas) registry.
(defn takes-int
{:malli/schema [:=> [:cat :int] :string]}
[x]
(str x))
Sequence/regex combinators outside the :=> head (e.g. :cat outside the function head, :alt,
:*, :+, :?, :repeat, :re, :fn) are not currently supported.
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'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., github-actions[bot] & Michael AndersonEdit 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 |