Compile-Time Checks: the HKJ Checker

What You'll Learn

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

javac-plugin-only boundary

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 idDetectsDefaultCompanion to a javac error?
path-type-mismatchDifferent Path types mixed in via/then/zipWith/zipWith3/recoverWith/orElseerrorNo: sole signal (else runtime IllegalArgumentException)
effect-compositionInterpreters.combine() called with an unsupported arityerrorCompanion
transformer-missing-monadZero-arg construction of EitherT/OptionalT/MaybeT/ReaderT/StateT/WriterTMonad (the outer Monad<F>, and Monoid<W> for WriterT, is required)errorCompanion
free-switch-exhaustiveA switch over Free matching Pure/Suspend/FlatMapped but missing HandleError/AperrorCompanion
discarded-effectA 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 happenederrorNo: sole signal
state-t-mapt-arityStateT.mapT(f) missing the leading Monad<G> (only StateT.mapT takes it)errorCompanion
error-type-mismatchAn Either chain step whose error type E differs from the chain's and is silently erased through Chainable<B> (latent ClassCastException)warnNo: sole signal
kind-value-narrow.value() on a bare Kind (it is on the concrete transformer; narrow first)errorCompanion
witness-arityA higher-kinded witness (type parameter or class) not WitnessArity-bounded, used as Kind/Kind2/Monad/Functor/ApplicativeerrorCompanion
via-non-pathvia/flatMap/then given a function that returns a plain value instead of a Path (use map)errorCompanion
map-nests-effectmap given a function that returns the same Path type: silently nests the effect; you meant viawarnNo: sole signal
migration-nudgeFree.liftF/Free.suspend (→ FreePath/*Ops) and InjectInstances.injectLeft/injectRight/injectRightThen (→ @ComposeEffects): valid code, an ergonomics nudgewarnNo: advisory
raw-kindA 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 KindUnwrapExceptionwarnNo: 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.

Eager and deferred paths

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.

Generated sources are not checked

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 (default error). It does not silently promote the warn-default checks (error-type-mismatch, map-nests-effect, migration-nudge, raw-kind). Those stay warn unless 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

HKJ compiles under its own checker

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.

Key Takeaways

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

See Also


Previous: Manual Gradle and Maven Setup Next: Migration Recipes