Custom Paths with @PathSource

Give your effect a named Path class, generated when you build.

What You'll Learn

  • Decide whether GenericPath is enough for your effect, or a generated Path is worth a build step
  • Generate a Path for your effect's witness, then compose with map, via and run
  • Predict which methods each capability level generates
  • Recover from your effect's own error type with errorType and RECOVERABLE
  • Name and place the generated class with suffix, targetPackage or a nested type

See Example Code

The code on this page is PathSourceBook.java, which the build compiles and runs. The two annotated effects sit beside it in the same package: Traced.java and Outcome.java, each with its witness, kind helper and Monad.

Say your library ships Traced<A>, a value together with the log of steps that produced it. GenericPath can wrap it, given a Monad you write for it, but every signature that holds one then spells out the effect's witness: GenericPath<TracedKind.Witness, Integer>. @PathSource has the annotation processor write a Path class for the effect instead, TracedPath<Integer>, with map, via and the rest declared on it.

Think of CompletableFuture, which gives an asynchronous task a named type with thenApply and thenCompose. A generated Path gives your effect the same: a named type with map and via, so callers compose it without handling its Kind. Unlike a CompletableFuture, it runs nothing itself. It holds your effect's value and the Monad that composes it.


When GenericPath is enough

Stay with GenericPath when your code needs ForPath or Path.from, or when your witness takes type arguments. Generate a Path when your users write the type in their own signatures, or when recovery should be typed by your effect's error.

You needGenericPath<F, A>A generated Path
No build step✓✗: the annotation processor writes it
A witness with type arguments, as in Result<E, A>✓✗: refused
ForPath, Path.from, a PathProvider✓✗
toMaybePath, toEitherPath, mapK, zipWith3✓✗
One type parameter in your users' signatures✗: GenericPath<TracedKind.Witness, A>✓: TracedPath<A>
A via that javac holds to the same Path✗: javac takes any Path, and only the HKJ checker reports another✓
A recover typed by your effect's error✗: each caller names the error type✓: at RECOVERABLE

ForPath has an entry point for GenericPath, and a PathProvider returns a Chainable, which is what Path.from hands back. A generated Path is neither: Chainable is sealed to the library's own Paths. The choice is not all or nothing, though. Path.generic takes a generated Path's Kind back into a GenericPath, with the same Monad, wherever one is needed:

    GenericPath<TracedKind.Witness, Integer> generic =
        Path.generic(priced.run(), TracedMonad.INSTANCE);

Generate your first Path

The running example is Traced. Here is what you write, and what the processor writes:

You writeWhy
A witness, the marker class Traced's Kind is indexed byThe generated Path wraps a Kind of it
A Monad over the witness, or a MonadError if the effect can failThe Path composes through it
A kind helper, whose narrow turns the Kind back into a Tracedrun hands back the Kind
@PathSource on the effect typeThe processor writes TracedPath from it

Extending the HKT simulation covers the first three in depth. The witness is a marker class inside the effect's Kind interface:

public interface TracedKind<A> extends Kind<TracedKind.Witness, A> {

  /** The witness: one type parameter, so a {@code Monad} can range over it. */
  final class Witness implements WitnessArity<TypeArity.Unary> {
    private Witness() {}
  }
}

Traced implements that interface, so a Traced is its own Kind, and it carries the annotation, naming the witness:

@PathSource(witness = TracedKind.Witness.class)
public record Traced<A>(A value, List<String> log) implements TracedKind<A> {

  public Traced {
    log = List.copyOf(log);
  }

  /** A value produced by one step. */
  public static <A> Traced<A> of(A value, String step) {
    return new Traced<>(value, List.of(step));
  }
}

The Monad keeps the log: map leaves it alone, and flatMap appends the next step's log to this one's. The kind helper, TracedKindHelper.TRACED, narrows a Kind back to a Traced, and throws KindUnwrapException for anything else, as each of the library's own helpers does.

TracedMonad

public enum TracedMonad implements Monad<TracedKind.Witness> {
  INSTANCE;

  @Override
  public <A> Kind<TracedKind.Witness, A> of(@Nullable A value) {
    return new Traced<>(value, List.of());
  }

  @Override
  public <A, B> Kind<TracedKind.Witness, B> map(
      Function<? super A, ? extends B> f, Kind<TracedKind.Witness, A> fa) {
    Traced<A> traced = TRACED.narrow(fa);
    return new Traced<>(f.apply(traced.value()), traced.log());
  }

  @Override
  public <A, B> Kind<TracedKind.Witness, B> ap(
      Kind<TracedKind.Witness, ? extends Function<A, B>> ff, Kind<TracedKind.Witness, A> fa) {
    return flatMap(f -> map(f, fa), ff);
  }

  @Override
  public <A, B> Kind<TracedKind.Witness, B> flatMap(
      Function<? super A, ? extends Kind<TracedKind.Witness, B>> f,
      Kind<TracedKind.Witness, A> ma) {
    Traced<A> first = TRACED.narrow(ma);
    Traced<B> next = TRACED.narrow(f.apply(first.value()));
    return new Traced<>(
        next.value(), Stream.concat(first.log().stream(), next.log().stream()).toList());
  }
}

Then build. The HKJ Gradle and Maven plugins put the annotation processor on the processor path, and add hkj-annotations, where @PathSource lives:

// build.gradle.kts
plugins {
    id("io.github.higher-kinded-j.hkj") version "LATEST_VERSION"
}

The Quickstart shows the Maven plugin, and Manual Setup a build without either. The processor writes TracedPath.java into the package of Traced. Gradle keeps it under build/generated/sources/annotationProcessor/java/main, and Maven under target/generated-sources/annotations.

TracedPath.of takes the Traced itself and the Monad that composes it. map changes the value and keeps the log. via runs a step that returns another TracedPath, and its log joins this one's. run hands back the Kind, which TRACED.narrow turns into a Traced:

    TracedPath<Integer> priced =
        TracedPath.of(Traced.of(1200, "priced the basket"), TracedMonad.INSTANCE);

    TracedPath<Integer> total =
        priced
            .map(pence -> pence * 6 / 5) // adds 20% VAT
            .via(
                pence ->
                    TracedPath.of(
                        Traced.of(pence - 200, "applied a voucher"), TracedMonad.INSTANCE));

    Traced<Integer> result = TRACED.narrow(total.run());
    // Traced[value=1240, log=[priced the basket, applied a voucher]]

The Path keeps the Monad you pass to of, because a generated class has no way to look one up. Your library can hide it from callers behind a factory of its own, such as a static method that returns TracedPath.of(traced, TracedMonad.INSTANCE).

via takes a function returning a TracedPath. Path.just returns a MaybePath, so javac reports bad return type in lambda expression:

TracedPath<Integer> discounted = priced.via(pence -> Path.just(pence - 200));

You can ship now

You can give your library's effect a Path of its own: annotate the type, build, and compose with map, via and run. If your effect can fail, read Recovery with errorType before you ship: RECOVERABLE changes what of and pure take, and a factory of your own keeps your callers clear of that. The rest of the page is for when you need more.


What gets generated

The processor writes one class, public final class TracedPath<A>, marked @Generated. Its name is the annotated type's name followed by Path, and it is written into the same package.

MemberWhat it does
of(kind, monad)Wraps a Kind of the witness, with the Monad that composes it
pure(value, monad)Starts from a plain value, through the Monad's of
run(), runKind()Return the wrapped Kind; the two are the same
map, peek, and the capability's methodsEach returns a new TracedPath: see Choosing a capability
equals, hashCodeCompare the wrapped Kind, so two Paths over equal Kinds are equal
toStringNames the class, then shows the Kind
    String shown = priced.toString();
    // TracedPath(Traced[value=1200, log=[priced the basket]])
    boolean same =
        priced.equals(TracedPath.of(Traced.of(1200, "priced the basket"), TracedMonad.INSTANCE));
    // true

The annotated type gives the Path its name, and its Javadoc link. The processor reads none of its methods, and does not check that the witness is the type's own.


Choosing a capability

capability decides which methods the class gets. Each level adds to the one before it, and the default is CHAINABLE. Chainable and the capability interfaces that extend it are sealed to the library's own Paths, so from COMBINABLE up the class implements Combinable, and declares the rest of its methods itself.

capabilityImplementsAddsChoose it when
COMPOSABLEComposable<A>map, peekCallers should only transform values
COMBINABLECombinable<A>zipWithCallers combine results that do not depend on each other
CHAINABLE (default)Combinable<A>via, then, flatMapCallers run one step after another
RECOVERABLECombinable<A>recover, recoverWith, mapErrorYour effect has an error type: see Recovery

Every level takes a Monad in of and pure. A lower level hides methods from your callers, but still needs the full Monad. The generated zipWith combines through the Monad's flatMap, so it runs this Path's step, then the other's:

    TracedPath<Integer> delivery =
        TracedPath.of(Traced.of(350, "quoted delivery"), TracedMonad.INSTANCE);

    Traced<Integer> withDelivery = TRACED.narrow(priced.zipWith(delivery, Integer::sum).run());
    // Traced[value=1550, log=[priced the basket, quoted delivery]]

zipWith combines only with another TracedPath

zipWith takes any Combinable, as the interface declares it, so javac accepts another Path there. The generated zipWith then throws IllegalArgumentException at the call, with a message beginning Cannot zipWith non-TracedPath:

TracedPath<Integer> mixed = priced.zipWith(Path.just(350), Integer::sum);

peek maps its action over the value, so the action runs when the effect produces the value. For an eager effect such as Traced that is at the call, and for a lazy one, each time the effect runs.


Recovery with errorType

Traced cannot fail. For an effect that can, set errorType to its error type and capability to RECOVERABLE. The example's Outcome holds either a value or a Problem:

@PathSource(
    witness = OutcomeKind.Witness.class,
    errorType = Problem.class,
    capability = PathSource.Capability.RECOVERABLE)
public sealed interface Outcome<A> extends OutcomeKind<A> {

  record Ok<A>(A value) implements Outcome<A> {}

  record Failed<A>(Problem problem) implements Outcome<A> {}
}

At RECOVERABLE, of and pure take a MonadError as well as the Monad. MonadError extends Monad, so one instance serves both: OutcomeMonad implements MonadError<OutcomeKind.Witness, Problem>, and the example passes it as each. recover, recoverWith and mapError then take functions of a Problem, so javac checks what each does with it:

    OutcomePath<String> reservation =
        OutcomePath.of(reserve("SKU-42"), OutcomeMonad.INSTANCE, OutcomeMonad.INSTANCE);

    Outcome<String> backOrdered =
        OUTCOME.narrow(reservation.recover(problem -> "back-ordered: " + problem.reason()).run());
    // Ok[value=back-ordered: SKU-42 is out of stock]

    Outcome<String> relabelled =
        OUTCOME.narrow(
            reservation.mapError(problem -> new Problem("checkout: " + problem.reason())).run());
    // Failed[problem=Problem[reason=checkout: SKU-42 is out of stock]]

A function written for another error type does not compile. The error reads Function<String,String> cannot be converted to Function<? super Problem,? extends String>:

OutcomePath<String> backOrdered =
    reservation.recover((String reason) -> "back-ordered: " + reason);

Why this matters

GenericPath declares recover over an error type each caller names, and nothing checks that name against the effect. Name it wrongly, and the code compiles, then throws ClassCastException when the error reaches it:

    GenericPath<OutcomeKind.Witness, String> reservation =
        GenericPath.of(reserve("SKU-42"), OutcomeMonad.INSTANCE);

    // compiles, and throws ClassCastException: the error is a Problem
    GenericPath<OutcomeKind.Witness, String> backOrdered =
        reservation.<String>recover(reason -> "back-ordered: " + reason.strip());

A generated Path types its recovery by errorType, so the same mistake fails the build instead.

Set errorType without RECOVERABLE, or the reverse, and the processor writes a note. The Path is generated without recovery methods. A note stops nothing, even under -Werror, so read the compiler output for it. Here errorType is set and capability left at its default:

@PathSource(witness = OutcomeKind.Witness.class, errorType = Problem.class)
public sealed interface Outcome<A> extends OutcomeKind<A> {

  record Ok<A>(A value) implements Outcome<A> {}

  record Failed<A>(Problem problem) implements Outcome<A> {}
}
Note: @PathSource: errorType 'Problem' has no effect on the generated 'OutcomePath'. The default
capability, CHAINABLE, generates no recovery methods; recover, recoverWith and mapError are
generated only for RECOVERABLE. For them, set capability = PathSource.Capability.RECOVERABLE: of
and pure then take a MonadError<OutcomeKind.Witness, Problem>, so existing calls must pass one.
Otherwise remove errorType.

The other half, RECOVERABLE with no errorType, draws a note that opens capability RECOVERABLE generates no recovery methods on 'OutcomePath' without an errorType:

@PathSource(witness = OutcomeKind.Witness.class, capability = PathSource.Capability.RECOVERABLE)
public sealed interface Outcome<A> extends OutcomeKind<A> {

  record Ok<A>(A value) implements Outcome<A> {}

  record Failed<A>(Problem problem) implements Outcome<A> {}
}

Naming and placement

The Path takes the annotated type's simple name, followed by suffix, and is written into the type's package unless targetPackage names another. For Traced in com.shop.trace:

You writeThe processor writes
@PathSource(witness = TracedKind.Witness.class)com.shop.trace.TracedPath
suffix = "Steps"com.shop.trace.TracedSteps
targetPackage = "com.shop.paths"com.shop.paths.TracedPath
suffix = "" and targetPackage = "com.shop.paths"com.shop.paths.Traced
@PathSource on Traced nested in a class Effectscom.shop.trace.TracedPath, a top-level class

A targetPackage needs the types the Path names to be public. The Path names the witness, and the error type at RECOVERABLE; the processor refuses either when it cannot reach it, as What the processor refuses quotes. The Path also imports the annotated type's top-level class for its Javadoc link. A targetPackage on a type whose top-level class is not public is not supported yet: the generated file does not compile.


The fine print

What the processor refuses

The processor reports each refusal as an error, in three sentences: what is wrong, why, and the fix. The fix sentence is the one to act on; in short:

The message saysWhat it meansFix
'Traced' is an enum@PathSource is on an enum or an annotation interfaceAnnotate the class, interface or record
which is not a Java identifierThe suffix leaves a name no class can haveUse letters, digits, _ or $
which Java does not allow as the name of a classThe suffix makes a reserved word, such as recordChoose another suffix
That is the type it is generated forAn empty suffix lands the Path on the annotated type itselfGive a suffix, or a targetPackage
A type of that name is already declared in the compilationAnother type already has the Path's nameChange the suffix, or rename that type
is not a package nametargetPackage is not a package nameGive a name such as com.shop.paths
witness 'int' is not a classThe witness is a primitive, void or an arrayName the witness marker class
is generic, and a class literal can name only its raw typeThe witness, or the error type at RECOVERABLE, takes type argumentsUse GenericPath, or a non-generic error type
witness 'String' is not a witness of one type parameterThe witness does not implement WitnessArity<TypeArity.Unary>Name the witness marker class
errorType 'int' is not a reference typeThe error type at RECOVERABLE is a primitive or voidUse a record describing the error
witness 'TraceWitness' cannot be reached from 'com.shop.paths'The Path's package cannot see the witness or the error typeMake it public, or drop targetPackage

A generic witness, such as EitherKind.Witness<L>, belongs with GenericPath, which takes its Kind with the type arguments. The fix in each message says so.

Declarations that produce them

An enum:

@PathSource(witness = TracedKind.Witness.class)
enum Traced { STARTED }

A suffix that is not part of a name:

@PathSource(witness = TracedKind.Witness.class, suffix = "-steps")
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A suffix that makes a reserved word, here record:

@PathSource(witness = TracedKind.Witness.class, suffix = "ord")
record rec<A>(A value, List<String> log) implements TracedKind<A> {}

An empty suffix, with nothing to keep the Path apart from the type:

@PathSource(witness = TracedKind.Witness.class, suffix = "")
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A Path name another type already has:

record TracedPath(String note) {}

@PathSource(witness = TracedKind.Witness.class)
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A target that is not a package name:

@PathSource(witness = TracedKind.Witness.class, targetPackage = "com.shop.trace paths")
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A witness that is not a class:

@PathSource(witness = int.class)
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A generic witness:

@PathSource(witness = EitherKind.Witness.class)
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A class that is not a witness:

@PathSource(witness = String.class)
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A primitive error type:

@PathSource(
    witness = OutcomeKind.Witness.class,
    errorType = int.class,
    capability = PathSource.Capability.RECOVERABLE)
interface Outcome<A> extends OutcomeKind<A> {}

A witness the Path's package cannot see:

final class TraceWitness implements WitnessArity<TypeArity.Unary> {}

@PathSource(witness = TraceWitness.class, targetPackage = "com.shop.paths")
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

A witness another processor writes

The witness may be a type another annotation processor generates in the same build. javac runs the processors again over each batch of generated sources, a round at a time, so @PathSource waits, and writes the Path in the round after the witness appears. A witness that never appears is left for javac to report: cannot find symbol, or package TracedKind does not exist for a Witness nested in a Kind interface that was never generated.

@EffectAlgebra writes such a witness, but an effect algebra comes with a Functor and no Monad, so you have nothing to pass to of. The Path is generated all the same, and the processor writes a note at the witness:

@EffectAlgebra
@PathSource(witness = ConsoleOpKind.Witness.class)
public sealed interface ConsoleOp<A> permits ConsoleOp.PrintLine {

  record PrintLine<A>(String message) implements ConsoleOp<A> {}
}
Note: @PathSource: 'ConsoleOpPath' needs a Monad<ConsoleOpKind.Witness>, and the effect algebra
'ConsoleOp' has a Functor only. The Path's of and pure take that Monad, and @EffectAlgebra
generates none, since an algebra's operations are instructions: Free chains them, and an
interpreter runs them. Build programs from ConsoleOpOps and wrap each in a FreePath with
Path.free(program, ConsoleOpFunctor.instance()), then remove @PathSource.

FreePath shows how to build such programs and interpret them.

Deprecated capabilities

EFFECTFUL and ACCUMULATING are deprecated for removal in 0.5.0. Neither generates what its name promises:

DeprecatedGenerates exactly what this doesSo it lacks
EFFECTFULCHAINABLEunsafeRun, delay and async
ACCUMULATINGRECOVERABLEError accumulation: zipWith stops at the first error
@PathSource(witness = TracedKind.Witness.class, capability = PathSource.Capability.EFFECTFUL)
record Traced<A>(A value, List<String> log) implements TracedKind<A> {}

javac warns at each use that EFFECTFUL in Capability has been deprecated and marked for removal, which fails a build under -Werror. The ReplaceDeprecatedPathSourceCapabilitiesRecipe replaces each with the level it generates.


Key Takeaways

  • Only GenericPath goes into ForPath, or comes from Path.from and a PathProvider; Path.generic takes a generated Path's Kind back to one
  • A generated Path is a named type, TracedPath<A>, whose via javac holds to itself
  • of and pure take your Monad, at every level, and the MonadError too at RECOVERABLE
  • Recovery needs both errorType and RECOVERABLE; either alone draws a note and no recovery
  • The annotated type names the Path: the witness is what it wraps, and the Monad what composes it

See Also


Previous: GenericPath Next: TrampolinePath