Kind Field Support in Focus DSL

Automatic Traversal for Higher-Kinded Type Fields

What You'll Learn

  • 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 @TraverseField for your own Kind types
  • What the semantic classifications (EXACTLY_ONE, ZERO_OR_ONE, ZERO_OR_MORE) decide
  • How traverseOver() and headOption() work together, and the one surprise in headOption's set

See Example Code

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

Why this matters

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 TypeTraverse InstanceSemanticsGenerated Path
ListKind.WitnessListTraverse.INSTANCEZERO_OR_MORETraversalPath
StreamKind.WitnessStreamTraverse.INSTANCEZERO_OR_MORETraversalPath
MaybeKind.WitnessMaybeTraverse.INSTANCEZERO_OR_ONEAffinePath
OptionalKind.WitnessOptionalTraverse.INSTANCEZERO_OR_ONEAffinePath
TryKind.WitnessTryTraverse.INSTANCEZERO_OR_ONEAffinePath
EitherKind.Witness<E>EitherTraverse.instance()ZERO_OR_ONEAffinePath
ValidatedKind.Witness<E>ValidatedTraverse.instance()ZERO_OR_ONEAffinePath
IdKind.WitnessIdTraverse.INSTANCEEXACTLY_ONEAffinePath

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): a TraversalPath, straight from traverseOver.
  • ZERO_OR_ONE (MaybeKind, OptionalKind, TryKind, EitherKind, ValidatedKind): a TraversalPath narrowed with headOption() to an AffinePath.
  • EXACTLY_ONE (IdKind): also narrowed to an AffinePath.

Why AffinePath for EXACTLY_ONE?

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()
AttributeTypeDescription
traverseStringA Java expression yielding the Traverse instance
semanticsKindSemanticsThe 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

The set is a setAll

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);

Key Takeaways

  • Convention over configuration. The library witnesses are detected by name and the right Traverse is applied for you.
  • KindSemantics is cardinality for witnesses, and picks the path type nearly as a container type does: zero-or-more gives a TraversalPath, zero-or-one an AffinePath. EXACTLY_ONE is the exception, landing on AffinePath where the type would justify a FocusPath.
  • @TraverseField opens the door to your own Kind types, with the Traverse given 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 FocusPath over the raw Kind, which you can still traverseOver by hand.

See Also

Further Reading

  • Monocle: Traversal: scala's traversal, the same abstraction
  • Cats: Traverse: the type class this support is built on

Previous: Focus DSL with External Libraries Next: Fluent API