The Instances Facade, Expanded Compile-Time Checks, and Hardened Migration Recipes
Overview
This release introduces Instances, a single static entry point for obtaining any built-in type-class instance, replacing the three inconsistent legacy idioms — a static INSTANCE field, a generic instance() method, or an argument-taking constructor — with one predictable shape discovered by capability through IDE autocomplete. The whole codebase (tests, runnable examples, and the book) is migrated to the one idiom.
It also grows the hkj-checker javac plugin from a single Path-type-mismatch check into a catalogue of twelve compile-time checks with per-check severity configuration, audits and hardens the hkj-openrewrite migration recipes (correctness fixes, type-safe matching, new 0.5.0 deprecation recipes, and a near-quadrupled test suite), gives every Effect Path type one standardised, debugging-friendly toString(), extends the collection-path fold family, and verifies the Bifunctor consistency laws across every canonical instance.
No breaking changes. All new features are additive. One method, StateTKind.narrowK, is now @Deprecated(forRemoval = true) and will be removed in 0.5.0; the type-safe narrow(Kind) is its replacement. The legacy instance accessors (MaybeMonad.INSTANCE, EitherMonad.instance(), …) remain in place. Existing code continues to compile and run unchanged.
Key Features
A Uniform Instances Facade for Type-Class Lookup
Historically, reaching a canonical type-class instance required knowing three independent things per type: the instance class name, its package, and which of three access idioms that class used. The new org.higherkindedj.hkt.instances.Instances facade collapses all of them into one predictable shape, Instances.x(...):
import org.higherkindedj.hkt.instances.Instances;
import static org.higherkindedj.hkt.instances.Witnesses.*;
// Zero-argument lookups — one token yields Functor/Applicative/Monad by subtyping
Monad<MaybeKind.Witness> monad = Instances.monad(maybe());
Applicative<MaybeKind.Witness> applicative = Instances.applicative(maybe());
Functor<MaybeKind.Witness> functor = Instances.functor(maybe());
// Phantom-typed witnesses still infer their type parameter from the target
Monad<EitherKind.Witness<DomainError>> e = Instances.monad(either());
// Argument-carrying instances put the required dependency in the method signature
MonadError<ValidatedKind.Witness<E>, E> v = Instances.validated(Semigroups.list());
Monad<WriterKind.Witness<String>> w = Instances.writer(Monoids.string());
MonadError<EitherTKind.Witness<F, L>, L> et = Instances.eitherT(Instances.monad(optional()));- Total
monad/applicative/functor— every canonical instance implements the wholeFunctor → Applicative → Monadchain in one object, so a single token fromWitnessesyields all three levels by Java subtyping. - Partial capability lookups —
Instances.monadError,monadZero, andalternativefor the canonical instances that implement the richer capability (Maybe,Optional,Try,Either,List,Stream). Asking for a capability an instance does not have fails fast with aClassCastException, exactly as calling a non-existent method would. The error typeEofmonadErroris inferred from the assignment target. - Argument-carrying re-exports —
validated(Semigroup),writer(Monoid),eitherT/maybeT/optionalT/readerT/stateT(outer), andwriterT(outer, Monoid). The structurally-required dependency is now a compiler-enforced, self-documenting method parameter instead of something discovered by reading a constructor. - Compile-time safe — a thin static re-export of the existing accessors. Not Spring-wired, not
PathRegistry/ServiceLoader-backed: every method resolves at compile time and no built-in instance can be missing at runtime. - One-idiom migration — adopted across ~196 test files, 66 runnable examples, and 71 book pages so the documentation and examples teach a single way to obtain an instance.
Traverse/Selective/Foldableand theMonadReader/MonadStateMTL capabilities remain a separate surface, intentionally out of scope.
See Obtaining Instances. (#522, #525)
Expanded hkj-checker Compile-Time Diagnostics
The hkj-checker javac plugin grows from a single check into a catalogue of twelve. Each check is either a companion to a real javac error (caught earlier, with a clearer message) or the sole signal for an otherwise-silent mistake — a discarded lazy effect, a silently-erased error type, a nested effect. The sole-signal heuristics default to a warning; the strict no-false-positives policy is preserved throughout.
| Check | Detects | Default |
|---|---|---|
path-type-mismatch |
Different Path types mixed in via/then/zipWith/recoverWith/orElse |
error |
effect-composition |
Interpreters.combine() called with an unsupported arity |
error |
transformer-missing-monad |
Zero-arg construction of a transformer that requires an outer Monad<F> |
error |
free-switch-exhaustive |
A switch over Free missing the HandleError/Ap cases |
error |
discarded-effect |
A lazy effect built then dropped as a bare statement — a silent no-op | error |
state-t-mapt-arity |
StateT.mapT(f) missing the leading Monad<G> |
error |
error-type-mismatch |
An Either-chain step whose error type E is silently erased |
warn |
kind-value-narrow |
.value() on a bare Kind (narrow to the concrete transformer first) |
error |
witness-arity |
A higher-kinded witness used as Kind/Monad/… without a WitnessArity bound |
error |
via-non-path |
via/flatMap/then given a function returning a plain value (use map) |
error |
map-nests-effect |
map given a function returning the same Path type (you meant via) |
warn |
migration-nudge |
Raw Free.liftF/Free.suspend and Inject boilerplate — an ergonomics nudge |
warn |
The plugin-argument grammar adds severity:<id>=error|warn alongside the global severity= and disable=<id>, so warn-default checks can be promoted per project. Unknown ids and unparseable values are ignored, so a typo never breaks the build. migration-nudge, free-switch-exhaustive, and witness-arity consolidate the relevant OpenRewrite diagnoses into compile-time feedback.
Several further candidate checks were investigated and deliberately not shipped — kept as passing characterization tests that document why — because the targeted error is unreachable on modern javac, already caught by the compiler, or only detectable via a rot-prone heuristic.
See Compile-Time Checks. (#529)
Hardened hkj-openrewrite Migration Recipes
The hkj-openrewrite migration recipes are audited and hardened — correctness fixes, broader detection, type-safe matching, new 0.5.0 deprecation recipes, and a test suite grown from 9 to 34 tests.
- Arity bounds —
AddArityBoundsToTypeParametersnow emitsTypeArity.BinaryforKind2,Bifunctor, andProfunctor(previously alwaysUnary, generating incorrect bounds), detects witness use across fields, local variables, the class hierarchy, nested generics, and wildcard bounds (not just method signatures), and no longer emits malformed output (<Fextends …>); the existing-bound intersection case is also fixed. - Type-safe detection —
ConvertRawFreeToFreePathandDetectInjectBoilerplateuse a type-attributedMethodMatcherinstead of rendered-string matching, so a user type namedFree, fully-qualified calls, or static imports no longer mis-fire or get missed.AddHandleErrorCasenow also handles switch expressions with whole-word case matching; the three detect-only recipes emit OpenRewrite search-result markers instead of rewriting source with TODO comments. - 0.5.0 deprecation migration — a new
MigrateDeprecationsTo0_5_0recipe group renamesStateTKind.narrowK→narrowandKindValidator.narrowWithPattern→narrowHolder. - Docs and packaging — the orphaned root
MIGRATION-0.3.0.mdis consolidated into a comprehensivehkj-openrewrite/README.mdcovering every recipe group. A new recipe-catalogue test validates that all recipes load from the classpath resources — which uncovered and fixed a pre-existing invalidAddWitnessArityImportsrecipe that listed a visitor as a recipe, so theAddArityBoundscomposite had been failing validation in every shipped release.
(#534)
Standardised Effect Path toString()
A shared PathToString helper gives every Effect Path type one debugging-friendly, greppable toString() convention: a round-parenthesis wrapper form TypeName(inner), a uniform angle-bracketed sentinel vocabulary, and bounded rendering for collection-backed paths so a large backing collection never produces an unbounded log line.
MaybePath(Just(42)) EitherPath(Left(error))
IdPath(null) OptionalPath(<empty>)
IOPath(<deferred>) StreamPath(<stream>)
CompletableFuturePath(<pending>) LazyPath(<failed>)
ListPath([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …(+5 more)])
IdPath is now null-safe and LazyPath never forces its computation when rendered. All changed path classes hold at 100% line/branch coverage.
See Effect Path Overview. (#530, #531)
Collection-Path Fold Family
ListPath and StreamPath gain the monoid-style fold(identity, op) and the Foldable-style foldMap(Monoid, fn), plus foldRight on StreamPath — matching the existing VStreamPath/VStreamContext fold surface, so the same reduction reads identically across every sequence-like path and stays inside the path chain.
// Reduce without unwrapping the path
int total = ListPath.of(1, 2, 3, 4).fold(0, Integer::sum); // 10
int sumOfLengths = StreamPath.of("a", "bb", "ccc")
.foldMap(Monoids.integerAddition(), String::length); // 6See Foldable and Traverse. (#462, #528)
Bug Fixes and Improvements
- Bifunctor consistency-law verification — the reusable Bifunctor law harness (
LawTestPattern/TypeClassTestPattern) now verifies the first-map (first(f, fab) == bimap(f, id, fab)) and second-map (second(g, fab) == bimap(id, g, fab)) consistency laws alongside the existing identity and composition laws, so every canonical instance (Either,Tuple,Const,Validated,Writer) is checked against all four; explicit named consistency tests are added forEither(Left/Right) andTuple(#461, #527) StateTKind.narrowKdeprecated —narrowKaccepts a wildcard-witnessKind<?, A>, bypassing the HKT witness type safety enforced everywhere else in the library. It has no callers and the type-safenarrow(Kind)already covers the use case, so it is now@Deprecated(forRemoval = true), scheduled for removal in 0.5.0. TheMigrateDeprecationsTo0_5_0OpenRewrite recipe automates the rename (#455, #526)- Request-batching substrate (internal) — a new module-internal
org.higherkindedj.optics.fetchpackage: a free-applicative-styleFetch<K, V, A>(Done/Blocked) andFetchApplicativethat plug into the opticmodifyFseam, so a traversal whose focused values are loaded from a backend coalesces those N loads into one batched call (the classic N+1). It ships a transport- and datastore-neutralBatchLoadercontract and round-basedrunCached/runAsyncrunners with a per-run request cache. The package is intentionally not exported — it is the foundation for later data-access capabilities and carries no public API yet; applicative laws are verified and the package is at 100% line/branch coverage (#539) - Java 25 toolchain auto-provisioning —
settings.gradle.ktsapplies thefoojay-resolver-conventionplugin so Gradle downloads and provisions a matching Java 25 JDK automatically when the build machine does not already have one, removing a manual setup step for new contributors (#531) - Javadoc errors fixed — the remaining Javadoc errors across all modules are resolved (#532)
Documentation
- New Obtaining Instances page documenting the
Instancesfacade, with a new glossary entry, a Type-Class Instances section in the cheat sheet, and anInstancesFacadeExamplerunnable example - Compile-Time Checks is now the authoritative
hkj-checkercatalogue — every check, its default severity, and the configuration grammar; the effect/transformers/optics Common Compiler Errors chapters were corrected where they described errors modern javac no longer emits and cross-linked to the catalogue - The collection-path fold family is documented in the Foldable and Traverse chapter and the cheat sheet, with
CollectionPathsExampleupdated to fold without unwrapping
Compatibility
No breaking changes. All new features are additive.
StateTKind.narrowKis@Deprecated(forRemoval = true)for removal in 0.5.0; migrate to the type-safenarrow(Kind). TheMigrateDeprecationsTo0_5_0OpenRewrite recipe automates the rename- The
Instancesfacade is purely additive — the legacy accessors (MaybeMonad.INSTANCE,EitherMonad.instance(),new EitherTMonad<>(outer), …) remain for binary compatibility. New code should prefer the facade so there is one idiom - Effect Path
toString()output format changed for consistency.toString()is for debugging only and is not part of the API contract — code should never parse it