Custom Paths with @PathSource
Give your effect a named Path class, generated when you build.
- Decide whether
GenericPathis 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,viaandrun - Predict which methods each
capabilitylevel generates - Recover from your effect's own error type with
errorTypeandRECOVERABLE - Name and place the generated class with
suffix,targetPackageor a nested type
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 need | GenericPath<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 write | Why |
|---|---|
A witness, the marker class Traced's Kind is indexed by | The generated Path wraps a Kind of it |
A Monad over the witness, or a MonadError if the effect can fail | The Path composes through it |
A kind helper, whose narrow turns the Kind back into a Traced | run hands back the Kind |
@PathSource on the effect type | The 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
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 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.
| Member | What 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 methods | Each returns a new TracedPath: see Choosing a capability |
equals, hashCode | Compare the wrapped Kind, so two Paths over equal Kinds are equal |
toString | Names 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.
capability | Implements | Adds | Choose it when |
|---|---|---|---|
COMPOSABLE | Composable<A> | map, peek | Callers should only transform values |
COMBINABLE | Combinable<A> | zipWith | Callers combine results that do not depend on each other |
CHAINABLE (default) | Combinable<A> | via, then, flatMap | Callers run one step after another |
RECOVERABLE | Combinable<A> | recover, recoverWith, mapError | Your 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 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);
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 write | The 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 Effects | com.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 says | What it means | Fix |
|---|---|---|
'Traced' is an enum | @PathSource is on an enum or an annotation interface | Annotate the class, interface or record |
which is not a Java identifier | The suffix leaves a name no class can have | Use letters, digits, _ or $ |
which Java does not allow as the name of a class | The suffix makes a reserved word, such as record | Choose another suffix |
That is the type it is generated for | An empty suffix lands the Path on the annotated type itself | Give a suffix, or a targetPackage |
A type of that name is already declared in the compilation | Another type already has the Path's name | Change the suffix, or rename that type |
is not a package name | targetPackage is not a package name | Give a name such as com.shop.paths |
witness 'int' is not a class | The witness is a primitive, void or an array | Name the witness marker class |
is generic, and a class literal can name only its raw type | The witness, or the error type at RECOVERABLE, takes type arguments | Use GenericPath, or a non-generic error type |
witness 'String' is not a witness of one type parameter | The witness does not implement WitnessArity<TypeArity.Unary> | Name the witness marker class |
errorType 'int' is not a reference type | The error type at RECOVERABLE is a primitive or void | Use 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 type | Make 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
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:
| Deprecated | Generates exactly what this does | So it lacks |
|---|---|---|
EFFECTFUL | CHAINABLE | unsafeRun, delay and async |
ACCUMULATING | RECOVERABLE | Error 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.
- Only
GenericPathgoes intoForPath, or comes fromPath.fromand aPathProvider;Path.generictakes a generated Path'sKindback to one - A generated Path is a named type,
TracedPath<A>, whoseviajavac holds to itself ofandpuretake yourMonad, at every level, and theMonadErrortoo atRECOVERABLE- Recovery needs both
errorTypeandRECOVERABLE; either alone draws a note and no recovery - The annotated type names the Path: the witness is what it wraps, and the
Monadwhat composes it
- GenericPath: the escape hatch every custom monad has without a build step
- Capability Interfaces: what
Composable,CombinableandChainablepromise - PathProvider registration: registering a
PathProvider, soPath.fromfinds a Path for your witness - Extending the HKT simulation: writing a witness, a helper and a
Monad - Effect Handlers:
@EffectAlgebra, for effects described as instructions
Previous: GenericPath Next: TrampolinePath