v0.4.10 (30 August 2026)
This release gives the mapper a stock vocabulary and takes every generating annotation through a generics pass: each one now emits source the consuming build compiles under -Werror, or explains at the declaration what it needs instead. The book gains a Mapping at the Boundary chapter with a law-checked capstone, and the house style it introduced now runs through the Optics and Resilience chapters.
Mapping at the Boundary (#670, #671, #672, #690, #752)
- A chapter of its own: the record-mapping material becomes Mapping at the Boundary, covering basics, codecs, structure, tiers, beans and sparse PATCH, generics, merge and envelopes, injection and testing. It opens with the response a client sees and closes with a compiled, law-checked capstone, One 422, Every Bad Field. The old
optics/record_mapping.htmlURL redirects. Two hands-on journeys sit beside it: Batching & Coupled Updates and Boundary Mapping. - Stock codec vocabulary:
StandardCodecsships oneValidatedPrismper standard conversion family (uuid,uri,localDate,instant,offsetDateTime,enumByName,bigDecimal, the numeric-from-string trio,booleanStrict,currency,locale), so a typical DTO boundary maps with no hand-written leaves. Every codec accepts exactly the canonical form it renders, and every failure is a locatedFieldErrorthat feeds the 422 leg unchanged. See Standard codecs. ValidatedPrism.canonical(message, parse, render)(plus aFieldErroroverload) wraps a throwing parser and a total render with the build-parse section law guarded per value: an accepted source must render back to itself, so a custom-canon codec is lawful without hand-writing the check. See Laws.- Records of any width:
parse, the validatedpatchand fallible@GenerateMergelegs assemble records beyond 16 components through chunkedValidated.fields()ladders, with the same located labels and declaration-order accumulation, law-checked at width. The only remaining bound is the JVM's constructor-slot limit on the record itself; the hand-writtenfields()ladder keeps its 16-field arity. See Diagnostics and limits. - One leaf vocabulary across tiers: the sparse
UpdateSpectier lifts element leaves over presentList,OptionalandMapproperties exactly as the dense tiers do, every failing element located (phones.1), so one mix-in vocabulary serves a spec and its PATCH sibling. See Sparse PATCH write-back. - Generic mix-ins: a spec may extend a mix-in declaring type parameters, so
extends Emails<EmailAddress>contributesValidatedPrism<String, EmailAddress>, read under the spec's instantiation. A generic ancestor reached through a rawextendsclause is still refused, naming the interface whose clause is raw. See Generic Specs.
Behaviour changes: a present same-typed identity container in a sparse PATCH now carries the dense tiers' null scan (tags.1: must not be null rather than a null reaching the domain); an Optional-typed PATCH property with a differing element type, previously rejected, patches through its element leaf; a spec extending a generic mix-in, previously refused, generates.
Generated optics for generic types (#721, #723, #730, #733, #736, #740, #742, #746, #750)
Every processor now reads a member or type under the instantiation the spec names rather than the source's own declaration, so a generic spec generates the signature it means, whatever it calls its parameters. Axis tests hold each generating annotation to the consuming build's -Xlint:unchecked,rawtypes -Werror.
@ImportOpticsdeclares the type parameters its signatures name, in the spec's declaration order with bounds carried along:BoxOpticsSpec<U> extends OpticsSpec<Box<U>>generates<U>, and a concrete instantiation declares none. An optic method declaring parameters of its own is refused.@GeneratePrismson a generic sealed hierarchy emits parameterised prisms. See Generic spec interfaces.@InstanceOfnarrows to what its test can check: the generatedinstanceofis written under the type arguments the source type pins (Circle<U>fromShape<U>), an unbounded wildcard elsewhere, and an array target through its component, and the generated methods no longer need@SuppressWarnings. A focus asking for more is refused with both remedies, widening to the wildcard or narrowing through@MatchWhen. See Parameterised targets.@GenerateIsosanswers a generic declaration at the declaration: an iso naming a type variable, an instance method, one taking arguments, one the generated package cannot see, or a return type that is not a two-argumentIsois each refused where it is written, with the remedy, rather than reported by javac inside the generated file. See Compiler Errors.@ViaCopyAndSet(copyConstructor = ...)selects the constructor it names: the attribute resolves to the supertype ofSit stands for and is emitted as a cast on the argument,new Config((BaseConfig) source), which is what picks between overloaded copy constructors. A name that does not resolve, is not a supertype, cannot be seen, or no constructor accepts is refused at the declaration. See Copy Strategies.- Type-use annotations reach generated source: a generated signature now carries the annotations the author wrote at every depth, so
@Nullable String notestays@Nullablein the generated lens, which matters inside a consumer's@NullMarkedpackage where a bare type means non-null. One exception by design: a focus widened through.nullable()drops the nullness the widening consumed, so@Nullable String labelisAffinePath<Box, String>whileList<@Nullable String> tagsisTraversalPath<Box, @Nullable String>.
Behaviour changes, source-breaking in narrow places: a generated @ImportOptics method's arity can fall (OpticsSpec<Pair<A, String>> now generates <A>, not <A, B>), so a call supplying explicit type witnesses stops compiling; drop them, the inferred result is unchanged. An @InstanceOf focus naming a type argument the source does not pin (Prism<Shape, Circle<T>> on OpticsSpec<Shape>), and a target that is a parameterised member of a generic type, are refused. A copyConstructor name that resolves now emits a cast, which can select a different constructor than ran in 0.4.9; check overloaded types with LensLaws. Generated signatures that now carry @Nullable can change what a nullness checker reports in the consuming build.
The Focus DSL gives one answer (#711, #718, #719, #725, #756)
- One widening analysis:
ShapesFocus.tags()andOuterFocus.shapes().tags()come from one declaration and now report one path type. A navigation method composes the static Focus method for the field it reaches, so the two cannot drift, and a navigated path carries its field-name segments forpathString(). See Path widening. SetandCollectioncomponents widen through theEachthat rebuilds them: aSetthroughEachInstances.setEach(), aCollectionthrough the newEachInstances.collectionEach(), and@GenerateTraversalsgains aCollectiongenerator. Every route to either, whether Focus,@GenerateTraversals,@ImportOpticsor@ThroughField, bottoms out in one rebuild policy inTraversals: source iteration order preserved, nulls carried through, an unmodifiable set for a set source and an unmodifiable list for anything else. See Supported container types and Collection Components.@Nullabledetection reaches every recognised annotation: JSpecify's (TYPE_USE), JetBrains', AndroidX's and SpotBugs'@Nullablenow widen to anAffinePathalongside JSR-305's and Jakarta's, and a container decides its own widening before@Nullableis consulted. See Focus DSL Reference.- A container the widening cannot be written for is explained at the declaration: an SPI container with a raw or wildcard type argument (
Either<String, ? extends Leaf>,Set<?>) has no ground instantiation to infer its optic instance from, so the diagnostic names the component and the concrete alternative, and the generated file still compiles, leaving one message rather than two. See Compiler Errors. @GenerateTraversalssays when it passes over a container: a component that is aCollectionorMapby erasure and reaches no generator (aDeque, aSortedMap, a rawList) draws a what/why/fix note where it is declared. A note rather than a warning, because the annotation has no per-component opt-out and a processor warning cannot be suppressed under-Werror. See Compiler Errors.
Behaviour changes, each replacing a path that threw ClassCastException on first use: a raw or wildcard Set or Collection component under @GenerateFocus is rejected; name the argument, or keep @GenerateLenses and @GenerateTraversals alone. Three navigator return types move to what the static method reports: a Collection subtype such as ArrayList is a FocusPath over the container; an SPI container of non-navigable elements stops at the container until the declaring record sets widenCollections = true; a nested List<Optional<String>> composes to the leaf, so drop the trailing .some(). A component carrying one of the four newly recognised @Nullables generates AffinePath where it generated FocusPath; read it with getOptional. Traversals.forSet() and traverseSet hand back an unmodifiable set (previously a mutable LinkedHashSet), and EachInstances.setEach() keeps source order across JVM runs.
Traversals for every component shape (#721)
@GenerateTraversalsreads a wildcard type argument:List<? extends Leaf>focusesLeaf, and?or? super TfocusesObject, forList,Mapand every HKJ and third-party container alike. Four more shapes now generate: an array of a type that cannot be created by name (List<Leaf>[],Leaf[][]), a primitive array (boxed through, unboxed back), a generic record (record Holder<T>(List<T> items)gets<T>on the method), and a record naming its ownF. Every container shape is compiled through the real processor by a sweep that also pins the element type each one focuses. See Wildcard Element Types and Generic Records.- Map traversals are linear:
Traversals.traverseMapValuesbuilds the result once rather than copying the map per entry (a 16 000-entry map traverses in 1.3 ms, down from 851 ms), and accepts a null key or a null the function hands back.
Spec interfaces explain what they cannot generate (#712, #723, #755)
- A generated prism's focus is a variant of its source:
@InstanceOfand@MatchWhenare both held to the rule at the declaration, since a prism built back with identity needs a focus that is itself a source. The book's examples now focus a variant, andPrism.ofis the route where the value type is the point. See Spec Interfaces. - A
defaultmethod on a spec interface is explained: a method body cannot be read during annotation processing, so the diagnostic names the two alternatives, astaticmethod on the interface or a utility class calling the generated statics, rather than generating a stub. See Compiler Errors. - One error per problem: a rejected
@InstanceOftarget, an undetectable@ThroughFieldcontainer or an unresolvablecopyConstructorreports once, without a second "requires a hint annotation" message.
@GeneratePathBridge emits a bridge the consuming build compiles (#746, #748)
Members are read under the annotated interface's own instantiation and deduplicated across superinterfaces, with throws carried, varargs kept, a self-referential bound still inferring, and the bridge class declaring the interface's type parameters, so a generic service interface bridges cleanly under -Werror. IO<T> methods bridge through Path.ioPath, an inherited @PathVia is picked up, and the six shapes with no correct rendering are explained at the declaration. See Compiler Errors.
Behaviour changes: a varargs delegate's bridge method is varargs, so a bare null argument now needs (String[]) null; a raw effect return type, a wildcard Validated error type, and a static or private @PathVia method are refused; an interface with no @PathVia method draws a processor warning.
@ComposeEffectsgenerates typed composition support:injectX()returnsInject<XKind.Witness, …>,functor(...)takes oneFunctorper effect and returns the composedEitherFFunctor, andBoundSet's components are each algebra'sBound, with no casts and no suppression. See Composing effects.@EffectAlgebraaccepts any name for the result type parameter, soRenamedOp<T>generates asOp<A>does, and the three permit shapes it cannot generate for (dropping, pinning or bounding the algebra's parameter) are explained at the declaration. See Defining effects.
Behaviour changes / migration: the last effect in a composition is now injected at the depth its arity implies, which corrects the dispatch of programs that reach it through the generated *Support. Each @ComposeEffects field must be Class<XOp<?>> naming an @EffectAlgebra; BoundSet<F> becomes BoundSet; functor() and BoundSet's accessors change erasure. A *Support is generated in your own build, so recompiling is the whole migration.
Compile-time checks
- HKJ compiles under its own checker: every module builds with
-Xplugin:HKJCheckerat zero findings.discarded-effectnow gates on a sealedDeferredcapability, soPath.io(() -> 1).peek(log)(a silent no-op) is reported whilePath.just(1).peek(log)(already run) is not, and arequireNonNullguard that hands the effect back is read as the pass-through it is. The checker skips@Generatedtypes, and a single declaration opts out of one check with@SuppressWarnings("<check-id>"), or of all of them with@SuppressWarnings("hkj-checker"). See Compile-Time Checks.
Documentation
- The house style: the mapping chapter established it (the payoff shown before the theory, the common case served before the fine print, mermaid for flows and decisions in a theme-safe palette, "Why this matters" beside the rule it justifies, Key Takeaways on every page), and it now runs through the Resilience chapter and every sub-chapter of Optics, the optics diagrams included. The style guide records the conventions.
Previous: Unreleased: 0.4.11 Next: v0.4.9