v0.4.10 (30 August 2026)

v0.4.10 on GitHub

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.html URL redirects. Two hands-on journeys sit beside it: Batching & Coupled Updates and Boundary Mapping.
  • Stock codec vocabulary: StandardCodecs ships one ValidatedPrism per 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 located FieldError that feeds the 422 leg unchanged. See Standard codecs.
  • ValidatedPrism.canonical(message, parse, render) (plus a FieldError overload) 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 validated patch and fallible @GenerateMerge legs assemble records beyond 16 components through chunked Validated.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-written fields() ladder keeps its 16-field arity. See Diagnostics and limits.
  • One leaf vocabulary across tiers: the sparse UpdateSpec tier lifts element leaves over present List, Optional and Map properties 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> contributes ValidatedPrism<String, EmailAddress>, read under the spec's instantiation. A generic ancestor reached through a raw extends clause 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.

  • @ImportOptics declares 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. @GeneratePrisms on a generic sealed hierarchy emits parameterised prisms. See Generic spec interfaces.
  • @InstanceOf narrows to what its test can check: the generated instanceof is written under the type arguments the source type pins (Circle<U> from Shape<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.
  • @GenerateIsos answers 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-argument Iso is 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 of S it 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 note stays @Nullable in the generated lens, which matters inside a consumer's @NullMarked package 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 label is AffinePath<Box, String> while List<@Nullable String> tags is TraversalPath<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() and OuterFocus.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 for pathString(). See Path widening.
  • Set and Collection components widen through the Each that rebuilds them: a Set through EachInstances.setEach(), a Collection through the new EachInstances.collectionEach(), and @GenerateTraversals gains a Collection generator. Every route to either, whether Focus, @GenerateTraversals, @ImportOptics or @ThroughField, bottoms out in one rebuild policy in Traversals: 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.
  • @Nullable detection reaches every recognised annotation: JSpecify's (TYPE_USE), JetBrains', AndroidX's and SpotBugs' @Nullable now widen to an AffinePath alongside JSR-305's and Jakarta's, and a container decides its own widening before @Nullable is 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.
  • @GenerateTraversals says when it passes over a container: a component that is a Collection or Map by erasure and reaches no generator (a Deque, a SortedMap, a raw List) 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)

  • @GenerateTraversals reads a wildcard type argument: List<? extends Leaf> focuses Leaf, and ? or ? super T focuses Object, for List, Map and 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 own F. 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.traverseMapValues builds 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: @InstanceOf and @MatchWhen are 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, and Prism.of is the route where the value type is the point. See Spec Interfaces.
  • A default method on a spec interface is explained: a method body cannot be read during annotation processing, so the diagnostic names the two alternatives, a static method 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 @InstanceOf target, an undetectable @ThroughField container or an unresolvable copyConstructor reports 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.

Effect handlers (#697, #699)

  • @ComposeEffects generates typed composition support: injectX() returns Inject<XKind.Witness, …>, functor(...) takes one Functor per effect and returns the composed EitherFFunctor, and BoundSet's components are each algebra's Bound, with no casts and no suppression. See Composing effects.
  • @EffectAlgebra accepts any name for the result type parameter, so RenamedOp<T> generates as Op<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:HKJChecker at zero findings. discarded-effect now gates on a sealed Deferred capability, so Path.io(() -> 1).peek(log) (a silent no-op) is reported while Path.just(1).peek(log) (already run) is not, and a requireNonNull guard that hands the effect back is read as the pass-through it is. The checker skips @Generated types, 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