Compile-Time Checks: the HKJ Checker
- Every check the HKJ compile-time checker performs, and what each catches
- How to configure (disable / change severity) individual checks
- The javac-plugin-only boundary, and the no-false-positives policy
The HKJ checker is a javac compiler plugin (hkj-checker) that catches
Higher-Kinded-J mistakes during compilation instead of at runtime (or,
for the silent ones, instead of never). It is the authoritative
reference for what is detected; the per-feature Common Compiler
Errors chapters cross-link here.
Why a checker
Many HKJ mistakes are silent: the code type-checks (generics erasure hides the problem) and only fails at runtime (or, worse, carries the wrong value silently). A representative example:
MaybePath<Integer> r = Path.just(1).via(n -> Path.io(() -> n + 1)); // IOPath, not MaybePath
This compiles, then throws IllegalArgumentException at runtime. The
checker turns this class of bug into a compile-time diagnostic.
The checker runs inside javac, driven by the HKJ Gradle/Maven plugin
(or a manual -Xplugin:HKJChecker). IDEs and non-javac compilers
do not run it. There you still see only the raw compiler message (or
nothing, for the silent cases). The Common Compiler Errors chapters
remain the reference for that audience; this page is the reference for
what the checker adds on top in a plugin-driven build/CI.
What the checker detects
Each check has an id (used for configuration). Default is the severity when nothing is configured; warn-default checks are the "sole signal over javac-accepted code" ones, kept at warning during soak.
| Check id | Detects | Default | Companion to a javac error? |
|---|---|---|---|
path-type-mismatch | Different Path types mixed in via/then/zipWith/zipWith3/recoverWith/orElse | error | No: sole signal (else runtime IllegalArgumentException) |
effect-composition | Interpreters.combine() called with an unsupported arity | error | Companion |
transformer-missing-monad | Zero-arg construction of EitherT/OptionalT/MaybeT/ReaderT/StateT/WriterTMonad (the outer Monad<F>, and Monoid<W> for WriterT, is required) | error | Companion |
free-switch-exhaustive | A switch over Free matching Pure/Suspend/FlatMapped but missing HandleError/Ap | error | Companion |
discarded-effect | A deferred path (IOPath/VTaskPath/LazyPath/FreePath — the Deferred capability) built then dropped as a bare statement, a silent no-op. Eager paths are out of scope: their work has already happened | error | No: sole signal |
state-t-mapt-arity | StateT.mapT(f) missing the leading Monad<G> (only StateT.mapT takes it) | error | Companion |
error-type-mismatch | An Either chain step whose error type E differs from the chain's and is silently erased through Chainable<B> (latent ClassCastException) | warn | No: sole signal |
kind-value-narrow | .value() on a bare Kind (it is on the concrete transformer; narrow first) | error | Companion |
witness-arity | A higher-kinded witness (type parameter or class) not WitnessArity-bounded, used as Kind/Kind2/Monad/Functor/Applicative | error | Companion |
via-non-path | via/flatMap/then given a function that returns a plain value instead of a Path (use map) | error | Companion |
map-nests-effect | map given a function that returns the same Path type: silently nests the effect; you meant via | warn | No: sole signal |
migration-nudge | Free.liftF/Free.suspend (→ FreePath/*Ops) and InjectInstances.injectLeft/injectRight/injectRightThen (→ @ComposeEffects): valid code, an ergonomics nudge | warn | No: advisory |
raw-kind | A raw Kind/Kind2 used as a type (variable, parameter, field or cast): drops the witness type argument, the one compile-silent route to a wrong-witness narrow() and a runtime KindUnwrapException | warn | No: sole signal |
"Companion" checks add an actionable HKJ message beside javac's own
cryptic error. "Sole signal" / "advisory" checks are the only
compile-time signal (the code otherwise compiles), which is why the
heuristic and advisory ones default to warn.
discarded-effect fires only on the Deferred capability, because only
there is a dropped statement a no-op:
Path.just(1).peek(log); // MaybePath: eager — log() has already run
Path.io(() -> 1).peek(log); // IOPath: deferred — log() never runs
Both discard their result; only the second did nothing. Deferred is a
sealed capability over IOPath, VTaskPath, VResultPath,
ReaderPath, WithStatePath, LazyPath, StreamPath, VStreamPath,
TrampolinePath and FreePath. CompletableFuturePath is excluded —
its future is already in flight — and so is GenericPath, whose
laziness depends on the F it wraps.
Every check is advice you are expected to act on, and nobody edits
annotation-processor output — so the checker skips any type annotated
org.higherkindedj.optics.annotations.Generated, wherever it sits in
its file, along with everything nested inside it.
Without that gate, @EffectAlgebra's generated *Ops class would carry
a migration-nudge telling you to use the generated *Ops smart
constructors — from inside the very class that provides them. Your own
call sites are checked as normal.
Only HKJ's own marker gates this. javax.annotation.processing.Generated
and its jakarta counterpart are not honoured: JaCoCo excludes any
type carrying an annotation named Generated, so those markers are
routinely hand-applied to hand-written code — as HKJ does itself with
org.higherkindedj.annotation.Generated. Honouring them would let a
coverage marker silently switch off every check on a file.
Configuration
Checks are configured through the plugin argument string. javac
splits -Xplugin on whitespace into the plugin name and its arguments:
-Xplugin:HKJChecker disable=<id>[,<id>...] severity=error|warn severity:<id>=error|warn
disable=<id>[,<id>...]: turn the listed checks off entirely (e.g.disable=map-nests-effect,discarded-effect).severity=error|warn: the global default for the error-default checks (defaulterror). It does not silently promote the warn-default checks (error-type-mismatch,map-nests-effect,migration-nudge,raw-kind). Those staywarnunless promoted explicitly.severity:<id>=error|warn: override one check. Wins over the global default and over a check's built-in default, so a team can promote a warn-default check, e.g.severity:error-type-mismatch=error, or downgrade a single error-default check.
Resolution order for a check: an explicit severity:<id>=… override;
else the warn-default checks stay warn; else the global severity.
Unknown ids and unparseable values are ignored rather than failing the build. A typo in a compiler argument must never break compilation.
Opting out one declaration
disable= is project-wide. To accept a single deliberate call site,
name the check id in @SuppressWarnings on the enclosing class, method
or variable — hkj-checker covers every check at once:
@SuppressWarnings("migration-nudge") // the ergonomic layer the nudge points at
public final class StateOps {
public static <S, A> Free<StateOpKind.Witness<S>, A> view(Getter<S, A> optic) {
// ...
return Free.liftF(StateOpKindHelper.STATE_OP.widen(op), functor());
}
}
A local variable takes the token too, which is the narrowest scope available:
@SuppressWarnings("path-type-mismatch") // deliberate: converted downstream
var mixed = Path.just(1).via(_ -> Path.io(() -> 2));
Suppression is per check and accumulates outward-in: a token on a class
reaches everything inside it, other checks on the same statement keep
running, and a sibling declaration is unaffected. HKJ's own smart-
constructor layer (StateOps, ErrorOps, OpticPrograms) and the
FreePath/FreeFactory fluent API use exactly this — they are the
layer migration-nudge steers callers towards, so they must build the
Free primitives directly.
Two deliberate limits. The tokens reach the error-default checks as
well as the advisory ones, so @SuppressWarnings("path-type-mismatch")
will silence a build-failing diagnostic — scope it narrowly. And javac's
blanket "all" is not honoured: it is too common on legacy and
generated code for a silent switch-off to be safe, so an HKJ opt-out is
always spelled explicitly.
Manual setup
Every module in this repository compiles with -Xplugin:HKJChecker
(hkj-checker itself excepted — it cannot run itself while being built,
and its fixtures violate the checks on purpose). The repo sits at zero
findings, and the -Werror ratchet makes a regression a build failure.
That is not ceremony: the library is the checks' most demanding user, and
two false positives — a requireNonNull guard read as a discarded
effect, and eager paths judged by the deferred rule — survived precisely
because the checker had never been pointed at this codebase. Deliberate
exceptions are spelled @SuppressWarnings("<check-id>") on the narrowest
enclosing declaration; the test suites carry them wherever a violation is
the behaviour under test.
The HKJ Gradle/Maven plugin enables the checker by default and passes
the -Xplugin argument through; see
Build Plugins. Without the build plugin:
Gradle
dependencies {
annotationProcessor("io.github.higher-kinded-j:hkj-checker:<version>")
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-Xplugin:HKJChecker disable=effect-composition")
}
Maven
<annotationProcessorPaths>
<path>
<groupId>io.github.higher-kinded-j</groupId>
<artifactId>hkj-checker</artifactId>
<version>${hkj.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Xplugin:HKJChecker severity=warn</arg>
</compilerArgs>
How it works
A standard com.sun.source.util.Plugin hooks the ANALYZE phase (after
type attribution). For each compilation unit a tree scanner runs the
enabled checks, resolving types via the public javax.lang.model
utilities (or, for the original mismatch check, javac's attributed
types) and emitting diagnostics at the exact source location. It is
part of normal compilation, with no separate build step.
No false positives
The checker follows a strict no-false-positives policy: it is better to miss a real mistake than to report a false one. When a type cannot be resolved (type variables, wildcards, method references, indirect construction, runtime polymorphism) the check is skipped silently. The library's runtime checks remain the safety net for anything the compiler cannot see.
- The checker catches 13 classes of HKJ mistake at compile time, including several that are otherwise entirely silent.
- Configure via
-Xplugin:HKJChecker disable=<id> severity=.... - javac-plugin-only: IDEs/non-javac builds still rely on the Common Compiler Errors chapters.
- No false positives: unresolved cases are skipped, not guessed.
- Build Plugins - plugin setup and configuration
- Effect Compiler Errors, Transformer Compiler Errors, Optics Compiler Errors - the per-feature references (and the IDE/non-plugin audience)
Previous: Manual Gradle and Maven Setup Next: Migration Recipes