Skip to content

The Instances Facade, Expanded Compile-Time Checks, and Hardened Migration Recipes

Choose a tag to compare

@MagnusSmith MagnusSmith released this 22 May 21:02
d24521a

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 whole Functor → Applicative → Monad chain in one object, so a single token from Witnesses yields all three levels by Java subtyping.
  • Partial capability lookupsInstances.monadError, monadZero, and alternative for 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 a ClassCastException, exactly as calling a non-existent method would. The error type E of monadError is inferred from the assignment target.
  • Argument-carrying re-exportsvalidated(Semigroup), writer(Monoid), eitherT/maybeT/optionalT/readerT/stateT(outer), and writerT(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/Foldable and the MonadReader/MonadState MTL 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 boundsAddArityBoundsToTypeParameters now emits TypeArity.Binary for Kind2, Bifunctor, and Profunctor (previously always Unary, 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 detectionConvertRawFreeToFreePath and DetectInjectBoilerplate use a type-attributed MethodMatcher instead of rendered-string matching, so a user type named Free, fully-qualified calls, or static imports no longer mis-fire or get missed. AddHandleErrorCase now 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_0 recipe group renames StateTKind.narrowKnarrow and KindValidator.narrowWithPatternnarrowHolder.
  • Docs and packaging — the orphaned root MIGRATION-0.3.0.md is consolidated into a comprehensive hkj-openrewrite/README.md covering every recipe group. A new recipe-catalogue test validates that all recipes load from the classpath resources — which uncovered and fixed a pre-existing invalid AddWitnessArityImports recipe that listed a visitor as a recipe, so the AddArityBounds composite 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);       // 6

See 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 for Either (Left/Right) and Tuple (#461, #527)
  • StateTKind.narrowK deprecatednarrowK accepts a wildcard-witness Kind<?, A>, bypassing the HKT witness type safety enforced everywhere else in the library. It has no callers and the type-safe narrow(Kind) already covers the use case, so it is now @Deprecated(forRemoval = true), scheduled for removal in 0.5.0. The MigrateDeprecationsTo0_5_0 OpenRewrite recipe automates the rename (#455, #526)
  • Request-batching substrate (internal) — a new module-internal org.higherkindedj.optics.fetch package: a free-applicative-style Fetch<K, V, A> (Done/Blocked) and FetchApplicative that plug into the optic modifyF seam, 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-neutral BatchLoader contract and round-based runCached/runAsync runners 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-provisioningsettings.gradle.kts applies the foojay-resolver-convention plugin 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 Instances facade, with a new glossary entry, a Type-Class Instances section in the cheat sheet, and an InstancesFacadeExample runnable example
  • Compile-Time Checks is now the authoritative hkj-checker catalogue — 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 CollectionPathsExample updated to fold without unwrapping

Compatibility

No breaking changes. All new features are additive.

  • StateTKind.narrowK is @Deprecated(forRemoval = true) for removal in 0.5.0; migrate to the type-safe narrow(Kind). The MigrateDeprecationsTo0_5_0 OpenRewrite recipe automates the rename
  • The Instances facade 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