FreePath
FreePath<F, A> wraps Free<F, A> for building domain-specific languages
(DSLs). It separates the description of a program from its execution,
enabling multiple interpreters for the same program.
- Creating FreePath instances
- Building DSL operations
- Writing interpreters
- When to use (and when not to)
The Idea
Free monads let you:
- Describe operations as data structures
- Compose descriptions into programs
- Interpret programs with different strategies
This enables testing with mock interpreters, swapping implementations, and reasoning about programs as data.
Defining a DSL
First, define your operations as a sum type (algebra):
// Console operations. Extending Kind gives the algebra a witness, which is what
// FreePath is parameterised by.
sealed interface ConsoleOp<A> extends Kind<ConsoleOp.Witness, A> permits Ask, Tell {
interface Witness extends WitnessArity<TypeArity.Unary> {}
}
record Ask<A>(String prompt, Function<String, A> next) implements ConsoleOp<A> {}
record Tell<A>(String message, A next) implements ConsoleOp<A> {}
Every algebra also needs a Functor, so FreePath can map over whatever an
operation carries next:
Functor<ConsoleOp.Witness> consoleFunctor = new Functor<>() {
@Override
public <A, B> Kind<ConsoleOp.Witness, B> map(
Function<? super A, ? extends B> f, Kind<ConsoleOp.Witness, A> fa) {
return switch ((ConsoleOp<A>) fa) {
case Ask<A>(String prompt, Function<String, A> next) ->
new Ask<B>(prompt, s -> f.apply(next.apply(s)));
case Tell<A>(String message, A next) ->
new Tell<B>(message, f.apply(next));
};
}
};
The Coyoneda lemma derives a Functor for any
instruction set, at the cost of an extra wrapper.
Creating Programs
Lift operations into FreePath:
FreePath<ConsoleOp.Witness, String> ask(String prompt) {
return Path.freeLift(new Ask<>(prompt, Function.identity()), consoleFunctor);
}
FreePath<ConsoleOp.Witness, Void> tell(String message) {
return Path.freeLift(new Tell<>(message, null), consoleFunctor);
}
Compose into programs:
FreePath<ConsoleOp.Witness, String> greetUser =
ask("What is your name?").via(name ->
tell("Hello, " + name + "!").map(v -> name));
Core Operations
// Pure value (no operations)
FreePath<ConsoleOp.Witness, Integer> pure = Path.freePure(42, consoleFunctor);
// Transform results
FreePath<ConsoleOp.Witness, String> asString = pure.map(n -> "Value: " + n);
// Chain operations
FreePath<ConsoleOp.Witness, Integer> chained = pure.via(n ->
ask("Continue?").map(s -> n + s.length()));
Interpreters
An interpreter is a natural transformation from your algebra to a target monad:
// Real console interpreter
NaturalTransformation<ConsoleOp.Witness, IO.Witness> realInterpreter =
new NaturalTransformation<>() {
@Override
public <A> Kind<IO.Witness, A> apply(Kind<ConsoleOp.Witness, A> fa) {
return switch ((ConsoleOp<A>) fa) {
case Ask<A> a -> IO.delay(() -> {
System.out.print(a.prompt() + " ");
return a.next().apply(scanner.nextLine());
});
case Tell<A> t -> IO.delay(() -> {
System.out.println(t.message());
return t.next();
});
};
}
};
// Test interpreter: the same algebra, answered from canned input
NaturalTransformation<ConsoleOp.Witness, IO.Witness> testInterpreter =
new NaturalTransformation<>() {
@Override
public <A> Kind<IO.Witness, A> apply(Kind<ConsoleOp.Witness, A> fa) {
return switch ((ConsoleOp<A>) fa) {
case Ask<A> a -> IO.delay(() -> a.next().apply("Alice"));
case Tell<A> t -> IO.delay(t::next);
};
}
};
Running Programs
FreePath<ConsoleOp.Witness, String> program = greetUser;
// Interpret the whole path in one step
GenericPath<IO.Witness, String> interpreted =
program.foldMapWith(realInterpreter, ioMonad);
// Execute
String result = IOKindHelper.IO_OP.narrow(interpreted.runKind()).unsafeRunSync();
// Or take the Free structure out first, if you want to fold it yourself
Free<ConsoleOp.Witness, String> free = program.toFree();
When to Use
FreePath is right when:
- You want to separate description from execution
- Multiple interpreters for the same program (prod/test/mock)
- Building embedded DSLs for domain operations
- You need to inspect or transform programs before running them
FreePath is wrong when:
- Simple direct effects suffice → use IOPath
- You don't need multiple interpreters
- Performance is critical (free monads have overhead)
- Operations can be parallelised → consider FreeApPath
// One program, two interpreters. Nothing about `greetUser` changes.
GenericPath<IO.Witness, String> live = greetUser.foldMapWith(realInterpreter, ioMonad);
GenericPath<IO.Witness, String> underTest = greetUser.foldMapWith(testInterpreter, ioMonad);
String answered = IOKindHelper.IO_OP.narrow(underTest.runKind()).unsafeRunSync();
- Free Monad - Underlying type for FreePath
- FreeApPath - Applicative variant for parallel operations
Previous: TrampolinePath Next: FreeApPath