Kind Field Support in Focus DSL
Automatic Traversal for Higher-Kinded Type Fields
- How the Focus DSL handles
Kind<F, A>record fields without any extra annotation - Convention-based detection for the library witnesses (
ListKind,MaybeKind, and the rest) - Using
@TraverseFieldfor your ownKindtypes - What the semantic classifications (
EXACTLY_ONE,ZERO_OR_ONE,ZERO_OR_MORE) decide - How
traverseOver()andheadOption()work together, and the one surprise inheadOption's set
Domain records in a Higher-Kinded-J codebase often hold their contents in a Kind<F, A>: a team's members as Kind<ListKind.Witness, Member>, an optional lead as Kind<MaybeKind.Witness, Member>. The processor recognises those witnesses and generates the traversal for you, so the field reads exactly like a plain List field would:
// Kind<ListKind.Witness, Member> members, and yet:
List<Member> everyone = TeamFocus.members().getAll(team);
Team levelled =
TeamFocus.members()
.via(MemberFocus.skills())
.modifyAll(Fixture::improve, team);
The Problem It Removes
Without automatic detection, every Kind field would need its Traverse instance threaded in by hand at each use site:
// What you would otherwise write, once per field, per call site
TraversalPath<Team, Member> memberPath =
FocusPath.of(TeamLenses.members()).<ListKind.Witness, Member>traverseOver(ListTraverse.INSTANCE);
The processor knows which Traverse belongs to ListKind.Witness, so the generated method has already applied it.
Convention-Based Detection
The Traverse for a witness is resolved once, by the processor, rather than threaded through every call site. Without this a Kind<F, A> field means passing an explicit Traverse instance to each operation that walks it, which is the ceremony that makes higher-kinded code look expensive in Java. Here the witness in the field's type is enough for the processor to pick the instance and the path type together.
A witness is the marker type that stands in for the higher-kinded F (see Higher-Kinded Types). The library's witnesses are recognised by name:
| Witness Type | Traverse Instance | Semantics | Generated Path |
|---|---|---|---|
ListKind.Witness | ListTraverse.INSTANCE | ZERO_OR_MORE | TraversalPath |
StreamKind.Witness | StreamTraverse.INSTANCE | ZERO_OR_MORE | TraversalPath |
MaybeKind.Witness | MaybeTraverse.INSTANCE | ZERO_OR_ONE | AffinePath |
OptionalKind.Witness | OptionalTraverse.INSTANCE | ZERO_OR_ONE | AffinePath |
TryKind.Witness | TryTraverse.INSTANCE | ZERO_OR_ONE | AffinePath |
EitherKind.Witness<E> | EitherTraverse.instance() | ZERO_OR_ONE | AffinePath |
ValidatedKind.Witness<E> | ValidatedTraverse.instance() | ZERO_OR_ONE | AffinePath |
IdKind.Witness | IdTraverse.INSTANCE | EXACTLY_ONE | AffinePath |
Parameterised witnesses are handled too: an EitherKind.Witness<String> field generates a call to EitherTraverse.<String>instance() with the type argument carried through.
// One record, three witnesses, three path types
AffinePath<ApiResponse, Member> lead = ApiResponseFocus.lead(); // MaybeKind
TraversalPath<ApiResponse, String> warnings = ApiResponseFocus.warnings(); // ListKind
AffinePath<ApiResponse, String> result = ApiResponseFocus.result(); // EitherKind<String>
Optional<Member> theLead = lead.getOptional(response);
List<String> allWarnings = warnings.getAll(response);
What the Semantics Decide
KindSemantics is cardinality for witnesses: it classifies a witness by how many values its type can hold, exactly as a container's Cardinality does for a plain field, and that classification picks the path type:
ZERO_OR_MORE(ListKind,StreamKind): aTraversalPath, straight fromtraverseOver.ZERO_OR_ONE(MaybeKind,OptionalKind,TryKind,EitherKind,ValidatedKind): aTraversalPathnarrowed withheadOption()to anAffinePath.EXACTLY_ONE(IdKind): also narrowed to anAffinePath.
An IdKind always holds exactly one value, so a FocusPath would be the honest type. The generation route does not allow it: traverseOver returns a TraversalPath, and the only narrowing available is headOption(), which lands on AffinePath. The result is correct at runtime, and one step weaker than the type could be.
Custom Kind Types with @TraverseField
For a witness outside the library, name the Traverse instance and the cardinality:
@GenerateFocus
record Forest(
String name,
@TraverseField(
traverse = "com.example.TreeTraverse.INSTANCE",
semantics = KindSemantics.ZERO_OR_MORE)
Kind<TreeKind.Witness, Tree> trees) {}
// Generates: TraversalPath<Forest, Tree> trees()
| Attribute | Type | Description |
|---|---|---|
traverse | String | A Java expression yielding the Traverse instance |
semantics | KindSemantics | The cardinality (default ZERO_OR_MORE) |
The expression is emitted verbatim, so any of these forms works:
@TraverseField(traverse = "com.example.TreeTraverse.INSTANCE") // enum singleton
@TraverseField(traverse = "com.example.TreeTraverse.instance()") // factory method
@TraverseField(traverse = "com.example.TreeTraverse.TRAVERSE") // static field
A Kind<F, A> field with an unrecognised witness and no @TraverseField is not an error: the processor falls back to a plain FocusPath focusing the raw Kind, and you apply traverseOver yourself.
headOption(): Narrowing a Traversal
headOption() turns a TraversalPath into an AffinePath. Its read is the first focused element; its write goes to every focused element, because that is what the underlying traversal does:
TraversalPath<Member, Skill> skills = MemberFocus.skills();
AffinePath<Member, Skill> firstSkill = skills.headOption();
Optional<Skill> first = firstSkill.getOptional(alice); // Optional[Skill[name=Java, proficiency=95]]
Member flattened = firstSkill.set(new Skill("Go", 50), alice);
// BOTH skills are now Skill[name=Go, proficiency=50]: the set is setAll underneath
For a ZERO_OR_ONE witness this is exactly right: there is at most one element, so setting "all" of them sets the one. On a genuinely multi-element traversal it is a trap. When you mean the first element only, index it (.at(0) from the path focusing the container) rather than narrowing with headOption().
Composing with the Rest of the DSL
Kind field paths are ordinary paths, so filtering, conditional modification and chaining all apply:
// Members who have a weak skill
TraversalPath<Team, Member> needsTraining =
TeamFocus.members().filter(m -> MemberFocus.skills().exists(s -> s.proficiency() < 50, m));
List<Member> juniors = needsTraining.getAll(team);
// Improve only their skills
Team afterTraining =
TeamFocus.members()
.modifyWhen(
m -> MemberFocus.skills().exists(s -> s.proficiency() < 50, m),
m -> MemberFocus.skills().modifyAll(Fixture::improve, m),
team);
- Convention over configuration. The library witnesses are detected by name and the right
Traverseis applied for you. KindSemanticsis cardinality for witnesses, and picks the path type nearly as a container type does: zero-or-more gives aTraversalPath, zero-or-one anAffinePath.EXACTLY_ONEis the exception, landing onAffinePathwhere the type would justify aFocusPath.@TraverseFieldopens the door to your ownKindtypes, with theTraversegiven as a verbatim expression.headOption()reads the first and writes to all. Sound for zero-or-one witnesses, a trap on a real traversal.- An unknown witness degrades gracefully to a
FocusPathover the rawKind, which you can stilltraverseOverby hand.
- Focus DSL: core concepts and path types
- Type Class and Effect Integration:
traverseOver()in the general case - Foldable and Traverse: the
Traversetype class itself - Core Type Integration:
Maybe,EitherandValidatedalongside optics
Previous: Focus DSL with External Libraries Next: Fluent API