The Effect Path API: Railway-Style Error Handling

Part 5 of the Functional Optics for Modern Java series

In Part 1 and Part 2, we established why optics matter and how they work. In Part 3, we built our expression language AST and applied basic optics using lenses for field access and prisms for variant matching. Last time in Part 4, we built traversals that visit every node in our expression tree. We implemented constant folding, identity simplification, and dead branch elimination. But all our transformations were pure: they took an expression and returned a new expression, with no side effects.

Real compilers and interpreters need more. Type checking should report all errors, not just the first one. Interpretation must track variable bindings as it descends through the tree. These are effects, and they change everything about how we should structure our code.

The Effect Path API from Higher-Kinded-J provides a fluent interface for computations that might fail, accumulate errors, or require deferred execution. This is the practical face of effect polymorphism, making powerful abstractions accessible through an ergonomic API.

Running the Examples


Article Code

All code examples from this article have runnable demos:

  • EffectPathDemo: Demonstrates the Effect Path API.
  • EffectPolymorphicDemo: Demonstrates effect-polymorphic optics using modifyF with different Higher-Kinded-J effects.
  • InterpreterDemo: Demonstrates expression interpretation using Higher-Kinded-J’s State monad.
  • ParallelTypeCheckerDemo: Constant folding, identity simplification, and cascading optimisation using traversal-based passes.
  • TypeCheckerDemo: Demonstrates parallel type checking using VTask and Scope.
  • VTaskPathDemo: Demonstrates VTaskPath for virtual thread-based concurrency.

Effects as Assembly Line Quality Control

Imagine you’re running an assembly line in a factory. Each station performs an operation on a product. At each station the outcome of the operation can follow a different path:

  • MaybePath: A station that might produce nothing (parts ran out). The line continues, but there’s no product to pass on.

  • EitherPath: A station with a fault inspector. If the product fails inspection, it’s immediately diverted to the rejection bin with a tag explaining why. The line stops for that product.

  • ValidationPath: A multi-point checklist inspection station. Every defect is recorded on a checklist, even if there are multiple problems. The product is only rejected after all checks are complete, and the checklist shows everything that needs fixing.

  • TryPath: A station that might malfunction. If it throws a wrench (literally), we catch the exception and treat it as data rather than letting it crash the whole factory.

  • IOPath: A station that doesn’t run until you press the “GO” button. The work is planned and sequenced, but nothing actually happens until you explicitly start it.

  • VTaskPath: A station that spawns lightweight workers for each task, coordinating them efficiently. Work happens concurrently on virtual threads, and the station can wait for all workers, race them, or collect their results.

The Effect Path API gives you these different assembly line configurations, letting you choose the right error handling strategy for each situation.


The Railway Model

Effect Paths follow the “railway” metaphor that was popularised by Scott Wlaschin. Values travel along tracks, and computations can switch between success and failure:

mfj-effect-polymorphic-1.png

The big idea here is that instead of throwing exceptions or returning null, Effect Paths make failure explicit in the type system. A MaybePath<String> might contain a string or might be empty. An EitherPath<Error, User> contains either an error or a user. A ValidationPath<List<Error>, Form> contains either accumulated errors or a valid form.

Ensuring failure is explicit in the type system has practical benefits:

  1. Compiler enforcement: We cannot ignore a potential failure; the types require handling it
  2. Composition: Effect Paths chain naturally with map, via, and zipWith
  3. Flexibility: Choose fail-fast (EitherPath) or accumulating (ValidationPath) behaviour

The Effect Path Types

Higher-Kinded-J provides many Effect Path types; here are six core types, each suited to different use cases:

Effect Path Contains Use Case
MaybePath<A> Value or nothing Optional data, silent failures
EitherPath<E, A> Error or value Fail-fast error handling
TryPath<A> Exception or value Wrapping throwing code
ValidationPath<E, A> Errors or value Accumulating all problems
IOPath<A> Deferred computation Side effects, resource management
VTaskPath<A> Virtual thread computation Concurrent operations, parallelism

Creating Effect Paths

The Path factory class provides convenient constructors:

import org.higherkindedj.hkt.effect.Path;

// MaybePath
MaybePath<String> present = Path.just("hello");
MaybePath<String> absent = Path.nothing();
MaybePath<String> nullable = Path.maybe(possiblyNullValue);

// EitherPath
EitherPath<String, Integer> success = Path.right(42);
EitherPath<String, Integer> failure = Path.left("Something went wrong");

// TryPath
TryPath<Integer> parsed = Path.tryOf(() -> Integer.parseInt(input));
TryPath<Integer> safe = Path.success(42);
TryPath<Integer> failed = Path.failure(new IllegalArgumentException("bad input"));

// ValidationPath (requires a Semigroup for error accumulation)
ValidationPath<List<Error>, User> valid = Path.valid(user, Semigroups.list());
ValidationPath<List<Error>, User> invalid = Path.invalid(errors, Semigroups.list());

// IOPath (deferred execution)
IOPath<String> readFile = Path.io(() -> Files.readString(path));
IOPath<Unit> sideEffect = Path.ioRunnable(() -> System.out.println("Hello"));

MaybePath: Optional Values

MaybePath<A> represents a computation that might not produce a value. It wraps Higher-Kinded-J’s Maybe type.

MaybePath<String> greeting = Path.just("Hello")
    .map(String::toUpperCase)
    .filter(s -> s.length() > 3)
    .map(s -> s + "!");

// Extract the result
String result = greeting.getOrElse("default");  // "HELLO!"

// Or pattern match
greeting.run().fold(
    () -> System.out.println("No value"),
    value -> System.out.println("Got: " + value)
);

Chaining with via

The via method chains dependent computations:

MaybePath<User> userPath = Path.just(userId)
    .via(id -> lookupUser(id))        // Returns MaybePath<User>
    .via(user -> validateUser(user)); // Returns MaybePath<User>

// If any step returns nothing, the chain short-circuits

Converting to Other Effect Paths

MaybePath<String> maybe = Path.just("hello");

// To EitherPath (provide error for empty case)
EitherPath<String, String> either = maybe.toEitherPath("Value was missing");

// To TryPath (provide exception for empty case)
TryPath<String> tryPath = maybe.toTryPath(() -> new NoSuchElementException());

// To ValidationPath (provide error and semigroup)
ValidationPath<List<Error>, String> validated =
    maybe.toValidationPath(List.of(new Error("missing")), Semigroups.list());

EitherPath: Fail-Fast Error Handling

EitherPath<E, A> represents a computation that either succeeds with a value or fails with a typed error. Unlike exceptions, the error type is explicit in the signature.

EitherPath<String, Integer> divide(int a, int b) {
    if (b == 0) {
        return Path.left("Division by zero");
    }
    return Path.right(a / b);
}

EitherPath<String, Integer> result = divide(10, 2)
    .map(n -> n * 2)
    .via(n -> divide(n, 3));

// Pattern match on the result
result.run().fold(
    error -> System.out.println("Error: " + error),
    value -> System.out.println("Result: " + value)
);

Error Transformation

// Map over the error type
EitherPath<Integer, String> withErrorCode =
    Path.<String, String>left("Not found")
        .mapError(msg -> 404);

// Recover from errors
EitherPath<String, Integer> recovered =
    Path.<String, Integer>left("Error")
        .recover(error -> -1);  // Replace error with default value

// Recover with another EitherPath
EitherPath<String, Integer> fallback =
    Path.<String, Integer>left("Primary failed")
        .recoverWith(error -> fetchFromBackup());

ValidationPath: Error Accumulation

ValidationPath<E, A> is the key type for comprehensive error reporting. Unlike EitherPath, which stops at the first error, ValidationPath collects all errors.

mfj-effect-polymorphic-2.png

// Define a semigroup constant to avoid repetition
private static final Semigroup<List<String>> ERRORS = Semigroups.list();

// Create validators that return ValidationPath
ValidationPath<List<String>, String> validateName(String name) {
    if (name == null || name.isBlank()) {
        return Path.invalid(List.of("Name is required"), ERRORS);
    }
    return Path.valid(name.trim(), ERRORS);
}

ValidationPath<List<String>, Integer> validateAge(int age) {
    if (age < 0) {
        return Path.invalid(List.of("Age cannot be negative"), ERRORS);
    }
    if (age > 150) {
        return Path.invalid(List.of("Age seems unrealistic"), ERRORS);
    }
    return Path.valid(age, ERRORS);
}

ValidationPath<List<String>, String> validateEmail(String email) {
    if (!email.contains("@")) {
        return Path.invalid(List.of("Invalid email format"), ERRORS);
    }
    return Path.valid(email, ERRORS);
}

Each validator returns a single error wrapped in a List because ValidationPath needs a Semigroup to combine errors from multiple validations. When two validations both fail, their List<String> errors are concatenated.

Combining Validations: Short-Circuit vs Accumulating

ValidationPath offers two composition modes:

Short-circuit (via via): Stops at first error, like EitherPath

// Sequential: second validation only runs if first succeeds
ValidationPath<List<String>, User> sequential = validateName(name)
    .via(n -> validateAge(age).map(a -> new User(n, a, null)));

Accumulating (via zipWithAccum): Collects all errors

// All validations run independently, errors accumulate
ValidationPath<List<String>, User> accumulated = validateName(name)
                .zipWith3Accum(
                        validateAge(age),
                        validateEmail(email),
                        (n, a, e) -> new User(n, a, e)
                );

For two validations, use zipWithAccum:

// Two-field example
record Contact(String name, String email) {}

ValidationPath<List<String>, Contact> contact = validateName(name)
    .zipWithAccum(validateEmail(email), Contact::new);

For three, use zipWith3Accum as shown above.

The Semigroup Requirement

ValidationPath requires a Semigroup<E> to combine errors. Higher-Kinded-J provides common semigroups:

import org.higherkindedj.hkt.Semigroups;

// List semigroup: concatenates lists
Semigroup<List<String>> listSemigroup = Semigroups.list();

// String semigroup: concatenates strings
Semigroup<String> stringSemigroup = Semigroups.string();

// Custom semigroup
Semigroup<ErrorReport> reportSemigroup = (a, b) -> a.merge(b);

TryPath: Exception Handling

TryPath<A> wraps computations that might throw exceptions, converting them to values:

// Wrap throwing code
TryPath<Integer> parsed = Path.tryOf(() -> Integer.parseInt(userInput));

// Chain operations safely
TryPath<Double> calculation = Path.tryOf(() -> Integer.parseInt(a))
    .map(x -> x * 2.0)
    .via(x -> Path.tryOf(() -> x / Double.parseDouble(b)));

// Handle the result
calculation.run().fold(
    value -> System.out.println("Result: " + value),
    ex -> System.out.println("Error: " + ex.getMessage())
);

Recovery from Exceptions

TryPath<Integer> withDefault = Path.tryOf(() -> Integer.parseInt(input))
    .recover(ex -> -1);  // Use -1 on parse failure

TryPath<Integer> withFallback = Path.tryOf(() -> fetchFromPrimary())
    .recoverWith(ex -> Path.tryOf(() -> fetchFromBackup()));

IOPath: Deferred Side Effects

IOPath<A> represents a computation that will be executed later. Nothing happens until you call unsafeRun() or runSafe().

// Define computations without executing them
IOPath<String> readConfig = Path.io(() -> Files.readString(configPath));
IOPath<Unit> writeLog = Path.ioRunnable(() -> logger.info("Operation complete"));

// Compose deferred computations
IOPath<Config> loadConfig = readConfig
    .map(json -> parseJson(json))
    .via(parsed -> validateConfig(parsed));

// Execute when ready
Config config = loadConfig.unsafeRun();  // Throws on error
Try<Config> safe = loadConfig.runSafe(); // Captures exceptions

Resource Management

IOPath provides safe resource handling with bracket and withResource:

// Bracket pattern: acquire, use, release
IOPath<String> content = IOPath.bracket(
    () -> Files.newBufferedReader(path),  // Acquire
    reader -> reader.lines().collect(Collectors.joining("\n")),  // Use
    reader -> reader.close()  // Release (always runs)
);

// For AutoCloseable resources
IOPath<String> simpler = IOPath.withResource(
    () -> Files.newBufferedReader(path),
    reader -> reader.lines().collect(Collectors.joining("\n"))
);

Parallel Execution and Retry

// Run two computations in parallel
IOPath<UserProfile> profile = fetchUser.parZipWith(
    fetchOrders,
    (user, orders) -> new UserProfile(user, orders)
);

// Retry with exponential backoff
IOPath<String> resilient = Path.io(() -> httpClient.get(url))
    .retry(3, Duration.ofMillis(100));  // 3 attempts, 100ms initial delay

VTaskPath: Virtual Thread Concurrency

VTaskPath<A> represents a computation that runs on Java’s virtual threads. It brings the lightweight concurrency of Project Loom to the Effect Path API, letting you write simple blocking code that scales to millions of concurrent operations.

// Create VTaskPaths
VTaskPath<String> fetchUser = Path.vtask(() -> userService.get(userId));
VTaskPath<String> fetchOrders = Path.vtask(() -> orderService.list(userId));

// Pure value (no computation)
VTaskPath<Integer> pure = Path.vtaskPure(42);

// Immediate failure
VTaskPath<String> failed = Path.vtaskFail(new IOException("Network error"));

// From a Runnable
VTaskPath<Unit> logAction = Path.vtaskExec(() -> logger.info("Starting..."));

Execution Model

Unlike IOPath, which runs on the caller’s thread, VTaskPath executes on virtual threads managed by the JVM. Virtual threads consume mere kilobytes of memory (versus megabytes for platform threads), enabling millions of concurrent tasks.

mfj-effect-polymorphic-3.png

VTaskPath<Integer> task = Path.vtask(() -> expensiveComputation());

// Three ways to execute
Integer result = task.run();           // Blocks, may throw
Try<Integer> safe = task.runSafe();    // Captures exceptions in Try
CompletableFuture<Integer> future = task.runAsync();  // Non-blocking

Composition

VTaskPath chains with the same map and via patterns as other Effect Paths:

VTaskPath<Dashboard> dashboard = Path.vtask(() -> fetchUser(id))
    .map(user -> user.preferences())
    .via(prefs -> Path.vtask(() -> buildDashboard(prefs)));

Parallel Execution with Par

The Par utility provides combinators for running VTasks concurrently:

import org.higherkindedj.hkt.vtask.Par;

// Combine two tasks in parallel
VTask<UserProfile> profile = Par.map2(
    VTask.of(() -> fetchUser(id)),
    VTask.of(() -> fetchOrders(id)),
    (user, orders) -> new UserProfile(user, orders)
);

// Execute a list of tasks in parallel
List<VTask<Integer>> tasks = ids.stream()
    .map(id -> VTask.of(() -> process(id)))
    .toList();
VTask<List<Integer>> allResults = Par.all(tasks);

// Race: first successful result wins
VTask<String> fastest = Par.race(List.of(
    VTask.of(() -> fetchFromMirror1()),
    VTask.of(() -> fetchFromMirror2())
));

Structured Concurrency with Scope

For more control over concurrent operations, use Scope. The three joiners determine how concurrent results are combined:

mfj-effect-polymorphic-4.png

import org.higherkindedj.hkt.vtask.Scope;

// Wait for all tasks to succeed
VTask<List<String>> all = Scope.<String>allSucceed()
    .fork(VTask.of(() -> fetchA()))
    .fork(VTask.of(() -> fetchB()))
    .fork(VTask.of(() -> fetchC()))
    .timeout(Duration.ofSeconds(5))
    .join();

// First success wins, cancel others
VTask<String> any = Scope.<String>anySucceed()
    .fork(VTask.of(() -> fetchFromPrimary()))
    .fork(VTask.of(() -> fetchFromBackup()))
    .join();

// Accumulate errors (like ValidationPath, but concurrent)
VTask<Validated<List<Error>, List<String>>> validated =
    Scope.<String>accumulating(Error::from)
        .fork(validateField1())
        .fork(validateField2())
        .fork(validateField3())
        .join();

Error Handling

// Replace error with a default value
VTaskPath<Config> withDefault = Path.vtask(() -> loadConfig())
                .handleError(ex -> Config.defaults());

// Or try a fallback task instead
VTaskPath<Config> withFallback = Path.vtask(() -> loadConfig())
        .handleErrorWith(ex -> Path.vtask(() -> loadFallbackConfig()));

Timeouts

VTaskPath<Data> withTimeout = Path.vtask(() -> slowOperation())
    .timeout(Duration.ofSeconds(5));

When to Use VTaskPath vs IOPath

Aspect VTaskPath IOPath
Thread model Virtual threads Caller’s thread
Parallelism Built-in via Par, Scope Manual composition
Structured concurrency Yes, with Scope No
Best for I/O-bound concurrent work Single-threaded effects

Choose VTaskPath when you need lightweight concurrency at scale. Choose IOPath when single-threaded execution is sufficient or when you need explicit control over which thread runs the computation.


Bridging Focus Paths and Effect Paths

The Focus DSL (FocusPath, AffinePath, TraversalPath) integrates seamlessly with Effect Paths. This bridge is where navigation meets computation. For a complete reference of all bridge methods, see the Focus-Effect Integration Guide.

mfj-effect-polymorphic-5.png

From Focus Paths to Effect Paths

// FocusPath to MaybePath (always succeeds since FocusPath has exactly one focus)
FocusPath<User, String> namePath = UserFocus.name();
MaybePath<String> name = namePath.toMaybePath(user);  // Always Just(value)

// AffinePath to MaybePath (may be empty)
AffinePath<User, String> nicknamePath = UserFocus.nickname();
MaybePath<String> nickname = nicknamePath.toMaybePath(user);  // Just or Nothing

// AffinePath to EitherPath (provide error for missing case)
EitherPath<String, String> nicknameOrError =
    nicknamePath.toEitherPath(user, "No nickname set");

Applying Focus Paths Within Effect Contexts

Effect Paths have a focus method that applies a FocusPath:

// Start with an Effect Path containing a User
EitherPath<Error, User> userPath = fetchUser(userId);

// Focus on a field within the effect context
EitherPath<Error, String> emailPath = userPath.focus(UserFocus.email());
// Equivalent to: userPath.map(user -> UserFocus.email().get(user))

// Chain multiple focuses
EitherPath<Error, String> city = userPath
    .focus(UserFocus.address())
    .focus(AddressFocus.city());

For AffinePath (which might not find a value), provide an error:

MaybePath<User> maybeUser = Path.just(user);
MaybePath<String> nickname = maybeUser.focus(
    UserFocus.nickname()  // AffinePath for optional field
);
// Returns Nothing if nickname is absent

EitherPath<String, User> eitherUser = Path.right(user);
EitherPath<String, String> nickname = eitherUser.focus(
    UserFocus.nickname(),
    "User has no nickname"  // Error if absent
);

Type Checking with ValidationPath

Let’s apply the Effect Path API to our expression language. Type checking is a perfect use case for ValidationPath: we want to report all type errors, not just the first one.

Defining Types and Errors

public enum Type { INT, BOOL, STRING }

public record TypeError(String message) {}

The Type Checker

public final class ExprTypeChecker {

    private static final Semigroup<List<TypeError>> ERRORS = Semigroups.list();

    public static ValidationPath<List<TypeError>, Type> typeCheck(Expr expr, TypeEnv env) {
        return switch (expr) {
            case Literal(var value) -> typeCheckLiteral(value);
            case Variable(var name) -> typeCheckVariable(name, env);
            case Binary(var left, var op, var right) -> typeCheckBinary(left, op, right, env);
            case Conditional(var cond, var then_, var else_) ->
                typeCheckConditional(cond, then_, else_, env);
        };
    }

    private static ValidationPath<List<TypeError>, Type> typeCheckLiteral(Object value) {
        return switch (value) {
            case Integer _ -> Path.valid(Type.INT, ERRORS);
            case Boolean _ -> Path.valid(Type.BOOL, ERRORS);
            case String _ -> Path.valid(Type.STRING, ERRORS);
            default -> Path.invalid(
                List.of(new TypeError("Unknown literal type: " + value.getClass().getSimpleName())),
                ERRORS
            );
        };
    }

    private static ValidationPath<List<TypeError>, Type> typeCheckVariable(String name, TypeEnv env) {
        return env.lookup(name)
            .map(type -> Path.valid(type, ERRORS))
            .orElseGet(() -> Path.invalid(
                List.of(new TypeError("Undefined variable: " + name)),
                ERRORS
            ));
    }

    private static ValidationPath<List<TypeError>, Type> typeCheckBinary(
            Expr left, BinaryOp op, Expr right, TypeEnv env) {
        // Use zipWithAccum to accumulate errors from both operands
        return typeCheck(left, env)
            .zipWithAccum(typeCheck(right, env), (lt, rt) -> checkBinaryTypes(op, lt, rt))
            .via(result -> result);  // Flatten nested validation
    }

    private static ValidationPath<List<TypeError>, Type> typeCheckConditional(
            Expr cond, Expr then_, Expr else_, TypeEnv env) {
        // Accumulate errors from all three sub-expressions
        return typeCheck(cond, env)
            .zipWith3Accum(
                typeCheck(then_, env),
                typeCheck(else_, env),
                ExprTypeChecker::checkConditionalTypes
            )
            .via(result -> result);
    }

    private static ValidationPath<List<TypeError>, Type> checkBinaryTypes(
            BinaryOp op, Type left, Type right) {
        return switch (op) {
            case ADD, SUB, MUL, DIV -> {
                if (left == Type.INT && right == Type.INT) {
                    yield Path.valid(Type.INT, ERRORS);
                }
                yield Path.invalid(List.of(new TypeError(
                    "Arithmetic operator '%s' requires INT operands, got %s and %s"
                        .formatted((op.symbol(), left, right)
                )), ERRORS);
            }
            case AND, OR -> {
                if (left == Type.BOOL && right == Type.BOOL) {
                    yield Path.valid(Type.BOOL, ERRORS);
                }
                yield Path.invalid(List.of(new TypeError(
                    "Logical operator '%s' requires BOOL operands, got %s and %s"
                        .formatted(op.symbol(), left, right)
                )), ERRORS);
            }
            case EQ, NE -> {
                if (left == right) {
                    yield Path.valid(Type.BOOL, ERRORS);
                }
                yield Path.invalid(List.of(new TypeError(
                        "Equality operator '%s' requires matching types, got %s and %s"
                                .formatted(op.symbol(), left, right)
                )), ERRORS);
            }
            case LT, LE, GT, GE -> {
                if (left == Type.INT && right == Type.INT) {
                    yield Path.valid(Type.BOOL, ERRORS);
                }
                yield Path.invalid(List.of(new TypeError(
                    "Comparison operator '%s' requires INT operands, got %s and %s"
                        .formatted(op.symbol(), left, right)
                )), ERRORS);
            }
        };
    }

    private static ValidationPath<List<TypeError>, Type> checkConditionalTypes(
            Type cond, Type then_, Type else_) {
        var condCheck = (cond == Type.BOOL)
                ? Path.valid(cond, ERRORS)
                : Path.invalid(List.of(new TypeError("Condition must be BOOL, got " + cond)), ERRORS);

        var branchCheck = (then_ == else_)
                ? Path.valid(then_, ERRORS)
                : Path.invalid(List.of(new TypeError(
                "Branches must have same type, got %s and %s".formatted(then_, else_))), ERRORS);

        // Accumulate errors from both checks using the applicative nature of ValidationPath
        return condCheck.zipWithAccum(branchCheck, (c, t) -> t);
    }
}

Running the Type Checker

// Expression with multiple errors: (1 + true) * (false && 42)
Expr expr = new Binary(
    new Binary(new Literal(1), BinaryOp.ADD, new Literal(true)),
    BinaryOp.MUL,
    new Binary(new Literal(false), BinaryOp.AND, new Literal(42))
);

ValidationPath<List<TypeError>, Type> result = ExprTypeChecker.typeCheck(expr, TypeEnv.empty());

result.run().fold(
    errors -> {
        System.out.println("Type errors:");
        for (TypeError error : errors) {
            System.out.println("  - " + error.message());
        }
    },
    type -> System.out.println("Type: " + type)
);

Output:

Type errors:
  - Arithmetic operator '+' requires INT operands, got INT and BOOL
  - Logical operator '&&' requires BOOL operands, got BOOL and INT

Both errors are reported in a single pass. The user can fix them both at once.


Understanding the Underlying Abstractions

The Effect Path API is built on Higher-Kinded-J’s type class hierarchy. Understanding these abstractions helps when you need maximum flexibility.

The modifyF Operation

Every optic supports modifyF, which generalises modification to work with any effect:

public interface Traversal<S, A> {
    <F extends WitnessArity<TypeArity.Unary>> Kind<F, S> modifyF(
        Function<A, Kind<F, A>> f,
        S source,
        Applicative<F> applicative
    );
}

The Applicative<F> parameter provides:

  1. of(a): Wrap a pure value in the effect
  2. map2(fa, fb, combine): Combine two effectful values

With just these operations, we can sequence independent computations while accumulating their effects.

Effect Path Types as Kind Wrappers

Each Effect Path type wraps a corresponding Kind<F, A>:

// MaybePath wraps Kind<Maybe.Witness, A>
MaybePath<String> maybePath = Path.just("hello");
Maybe<String> underlying = maybePath.run();

// EitherPath wraps Kind<Either.Witness<E, ?>, A>
EitherPath<String, Integer> eitherPath = Path.right(42);
Either<String, Integer> underlying = eitherPath.run();

The Effect Path API provides ergonomic methods that delegate to these underlying types.

When to Use modifyF Directly

For most use cases, the Effect Path API suffices. Use modifyF directly when:

  • You’re building reusable library code
  • You need to work with custom effect types
  • You want maximum composability with optics
// Using modifyF directly with a traversal
TraversalPath<Company, Employee> allEmployees = CompanyFocus
    .departments().each()
    .employees().each();

Kind<ValidatedKind.Witness<List<Error>>, Company> result = allEmployees.modifyF(
    emp -> validateEmployee(emp),
    company,
    ValidatedApplicative.instance(Semigroups.list())
);

Effect Path API vs modifyF: Choosing Your Level

Higher-Kinded-J provides two levels of abstraction:

Level API Best For
High Effect Path API Most application code, clear intent
Low modifyF with Kind<F, A> Libraries, custom effects, maximum flexibility

High-Level: Effect Path API

// Clear, fluent, discoverable
ValidationPath<List<String>, User> validated = validateName(name)
                .zipWith3Accum(validateAge(age), validateEmail(email), User::new);

Low-Level: modifyF with Applicative

// Maximum control, composable with any optic
Traversal<User, String> nameLens = UserLenses.name().asTraversal();
Kind<ValidatedKind.Witness<List<Error>>, User> result = nameLens.modifyF(
    name -> validateName(name),
    user,
    ValidatedApplicative.instance(Semigroups.list())
);

Start with the Effect Path API. Drop to modifyF when you need its power.


Summary

We introduced the Effect Path API for effectful programming:

  1. Effect Path types: MaybePath, EitherPath, TryPath, ValidationPath, IOPath, VTaskPath
  2. Railway model: Values travel success/failure tracks with explicit error handling
  3. ValidationPath: Accumulate all errors with zipWithAccum and zipWith3Accum
  4. VTaskPath: Virtual thread concurrency with Par and Scope for parallel operations
  5. Bridge methods: Connect Focus paths to Effect paths via toMaybePath, toEitherPath
  6. Type checking example: Comprehensive error reporting with ValidationPath

The Effect Path API makes effect polymorphism practical. The same patterns that work for optional values work for error handling, validation, and deferred execution. Choose the right Effect Path type for your use case, and let composition do the rest.


Further Reading

Effect Systems and Functional Programming

Error Handling Patterns

Higher-Kinded Types and Functional Abstractions

  • Algebraic Data Types with Java (Scott Logic, 2025): A thorough introduction to algebraic data types using Java’s sealed interfaces and records. Covers how sum types and product types compose to model complex domains.

  • Functors and Monads with Java and Scala (Scott Logic, 2025): A practical comparison of how Functor and Monad abstractions are implemented in Java vs Scala, directly relevant to understanding the Effect Path API’s foundation.

  • Higher-Kinded Types with Java and Scala (Scott Logic, 2025): Explores how higher-kinded types work and how Java can simulate them, providing context for understanding the Kind<F, A> pattern used throughout Higher-Kinded-J.

Higher-Kinded-J


Next time

We’ve now built a substantial expression language: AST definition, optics generation, tree traversals, optimisation passes, type checking, and the Effect Path API for error accumulation.

In the final Part 6, we’ll step back and reflect on what we’ve built:

  • The complete pipeline: From source text through parsing, type checking, optimisation, and evaluation
  • Design patterns: Emergent patterns for effect-polymorphic code that work well
  • Performance considerations: When to use optics and when simpler approaches suffice
  • Real-world applications: Applying these techniques beyond expression languages