{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreid6rdg67wtys3se5pzbds3ni3iqshbv2k2qnz4obuarlqurezu6aq",
"uri": "at://did:plc:vdlfckiwtrxaiw5e6ky6vz4z/app.bsky.feed.post/3mehd7qg35gj2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreicbjibhct4g6gkxjpc4iroglrxk5pnf4b6gaj2hn3yqrjalypckxq"
},
"mimeType": "image/jpeg",
"size": 76234
},
"path": "/2026/02/09/effect-polymorphic-optics.html",
"publishedAt": "2026-02-09T00:00:00.000Z",
"site": "https://blog.scottlogic.com",
"tags": [
"Effect Path API",
"runnable demos:",
"EffectPathDemo",
"EffectPolymorphicDemo",
"InterpreterDemo",
"ParallelTypeCheckerDemo",
"TypeCheckerDemo",
"VTaskPathDemo",
"Effect Path types",
"Focus DSL",
"Focus-Effect Integration Guide",
"“Railway Oriented Programming” video",
"slides",
"“Applicative programming with effects”",
"Handling Errors Without Exceptions",
"Algebraic Data Types with Java",
"Functors and Monads with Java and Scala",
"Higher-Kinded Types with Java and Scala",
"Effect Path API Guide",
"Path Factory",
"Semigroups",
"Focus DSL Guide",
"VTask and Structured Concurrency",
"Focus-Effect Integration"
],
"textContent": "# The Effect Path API: Railway-Style Error Handling\n\n_Part 5 of the Functional Optics for Modern Java series_\n\nIn 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.\n\nReal 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.\n\nThe **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.\n\n### Running the Examples\n\n* * *\n\n## Article Code\n\n**All code examples from this article haverunnable demos:**\n\n * **EffectPathDemo** : Demonstrates the Effect Path API.\n * **EffectPolymorphicDemo** : Demonstrates effect-polymorphic optics using modifyF with different Higher-Kinded-J effects.\n * **InterpreterDemo** : Demonstrates expression interpretation using Higher-Kinded-J’s State monad.\n * **ParallelTypeCheckerDemo** : Constant folding, identity simplification, and cascading optimisation using traversal-based passes.\n * **TypeCheckerDemo** : Demonstrates parallel type checking using VTask and Scope.\n * **VTaskPathDemo** : Demonstrates VTaskPath for virtual thread-based concurrency.\n\n\n\n* * *\n\n## Effects as Assembly Line Quality Control\n\nImagine 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:\n\n * **MaybePath** : A station that might produce nothing (parts ran out). The line continues, but there’s no product to pass on.\n\n * **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.\n\n * **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.\n\n * **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.\n\n * **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.\n\n * **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.\n\n\n\n\nThe Effect Path API gives you these different assembly line configurations, letting you choose the right error handling strategy for each situation.\n\n* * *\n\n## The Railway Model\n\nEffect Paths follow the “railway” metaphor that was popularised by Scott Wlaschin. Values travel along tracks, and computations can switch between success and failure:\n\nThe 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.\n\nEnsuring failure is explicit in the type system has practical benefits:\n\n 1. **Compiler enforcement** : We cannot ignore a potential failure; the types require handling it\n 2. **Composition** : Effect Paths chain naturally with `map`, `via`, and `zipWith`\n 3. **Flexibility** : Choose fail-fast (`EitherPath`) or accumulating (`ValidationPath`) behaviour\n\n\n\n* * *\n\n## The Effect Path Types\n\nHigher-Kinded-J provides many Effect Path types; here are six core types, each suited to different use cases:\n\nEffect Path | Contains | Use Case\n---|---|---\n`MaybePath<A>` | Value or nothing | Optional data, silent failures\n`EitherPath<E, A>` | Error or value | Fail-fast error handling\n`TryPath<A>` | Exception or value | Wrapping throwing code\n`ValidationPath<E, A>` | Errors or value | Accumulating all problems\n`IOPath<A>` | Deferred computation | Side effects, resource management\n`VTaskPath<A>` | Virtual thread computation | Concurrent operations, parallelism\n\n### Creating Effect Paths\n\nThe `Path` factory class provides convenient constructors:\n\n\n 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\"));\n\n* * *\n\n## MaybePath: Optional Values\n\n`MaybePath<A>` represents a computation that might not produce a value. It wraps Higher-Kinded-J’s `Maybe` type.\n\n\n 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) );\n\n### Chaining with via\n\nThe `via` method chains dependent computations:\n\n\n 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\n\n### Converting to Other Effect Paths\n\n\n 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());\n\n* * *\n\n## EitherPath: Fail-Fast Error Handling\n\n`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.\n\n\n 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) );\n\n### Error Transformation\n\n\n // 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());\n\n* * *\n\n## ValidationPath: Error Accumulation\n\n`ValidationPath<E, A>` is the key type for comprehensive error reporting. Unlike `EitherPath`, which stops at the first error, `ValidationPath` collects all errors.\n\n\n // 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); }\n\nEach 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.\n\n### Combining Validations: Short-Circuit vs Accumulating\n\nValidationPath offers two composition modes:\n\n**Short-circuit** (via `via`): Stops at first error, like EitherPath\n\n\n // 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)));\n\n**Accumulating** (via `zipWithAccum`): Collects all errors\n\n\n // 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) );\n\nFor two validations, use `zipWithAccum`:\n\n\n // Two-field example record Contact(String name, String email) {} ValidationPath<List<String>, Contact> contact = validateName(name) .zipWithAccum(validateEmail(email), Contact::new);\n\nFor three, use `zipWith3Accum` as shown above.\n\n### The Semigroup Requirement\n\nValidationPath requires a `Semigroup<E>` to combine errors. Higher-Kinded-J provides common semigroups:\n\n\n 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);\n\n* * *\n\n## TryPath: Exception Handling\n\n`TryPath<A>` wraps computations that might throw exceptions, converting them to values:\n\n\n // 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()) );\n\n### Recovery from Exceptions\n\n\n 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()));\n\n* * *\n\n## IOPath: Deferred Side Effects\n\n`IOPath<A>` represents a computation that will be executed later. Nothing happens until you call `unsafeRun()` or `runSafe()`.\n\n\n // 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\n\n### Resource Management\n\nIOPath provides safe resource handling with `bracket` and `withResource`:\n\n\n // 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\")) );\n\n### Parallel Execution and Retry\n\n\n // 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\n\n* * *\n\n## VTaskPath: Virtual Thread Concurrency\n\n`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.\n\n\n // 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...\"));\n\n### Execution Model\n\nUnlike `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.\n\n\n 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\n\n### Composition\n\nVTaskPath chains with the same `map` and `via` patterns as other Effect Paths:\n\n\n VTaskPath<Dashboard> dashboard = Path.vtask(() -> fetchUser(id)) .map(user -> user.preferences()) .via(prefs -> Path.vtask(() -> buildDashboard(prefs)));\n\n### Parallel Execution with Par\n\nThe `Par` utility provides combinators for running VTasks concurrently:\n\n\n 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()) ));\n\n### Structured Concurrency with Scope\n\nFor more control over concurrent operations, use `Scope`. The three joiners determine how concurrent results are combined:\n\n\n 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();\n\n### Error Handling\n\n\n // 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()));\n\n### Timeouts\n\n\n VTaskPath<Data> withTimeout = Path.vtask(() -> slowOperation()) .timeout(Duration.ofSeconds(5));\n\n### When to Use VTaskPath vs IOPath\n\nAspect | VTaskPath | IOPath\n---|---|---\n**Thread model** | Virtual threads | Caller’s thread\n**Parallelism** | Built-in via `Par`, `Scope` | Manual composition\n**Structured concurrency** | Yes, with `Scope` | No\n**Best for** | I/O-bound concurrent work | Single-threaded effects\n\nChoose `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.\n\n* * *\n\n## Bridging Focus Paths and Effect Paths\n\nThe 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.\n\n### From Focus Paths to Effect Paths\n\n\n // 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\");\n\n### Applying Focus Paths Within Effect Contexts\n\nEffect Paths have a `focus` method that applies a FocusPath:\n\n\n // 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());\n\nFor AffinePath (which might not find a value), provide an error:\n\n\n 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 );\n\n* * *\n\n## Type Checking with ValidationPath\n\nLet’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.\n\n### Defining Types and Errors\n\n\n public enum Type { INT, BOOL, STRING } public record TypeError(String message) {}\n\n### The Type Checker\n\n\n 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); } }\n\n### Running the Type Checker\n\n\n // 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) );\n\nOutput:\n\n\n Type errors: - Arithmetic operator '+' requires INT operands, got INT and BOOL - Logical operator '&&' requires BOOL operands, got BOOL and INT\n\nBoth errors are reported in a single pass. The user can fix them both at once.\n\n* * *\n\n## Understanding the Underlying Abstractions\n\nThe Effect Path API is built on Higher-Kinded-J’s type class hierarchy. Understanding these abstractions helps when you need maximum flexibility.\n\n### The modifyF Operation\n\nEvery optic supports `modifyF`, which generalises modification to work with any effect:\n\n\n 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 ); }\n\nThe `Applicative<F>` parameter provides:\n\n 1. **`of(a)`** : Wrap a pure value in the effect\n 2. **`map2(fa, fb, combine)`** : Combine two effectful values\n\n\n\nWith just these operations, we can sequence independent computations while accumulating their effects.\n\n### Effect Path Types as Kind Wrappers\n\nEach Effect Path type wraps a corresponding `Kind<F, A>`:\n\n\n // 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();\n\nThe Effect Path API provides ergonomic methods that delegate to these underlying types.\n\n### When to Use modifyF Directly\n\nFor most use cases, the Effect Path API suffices. Use `modifyF` directly when:\n\n * You’re building reusable library code\n * You need to work with custom effect types\n * You want maximum composability with optics\n\n\n\n\n // 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()) );\n\n* * *\n\n## Effect Path API vs modifyF: Choosing Your Level\n\nHigher-Kinded-J provides two levels of abstraction:\n\nLevel | API | Best For\n---|---|---\n**High** | Effect Path API | Most application code, clear intent\n**Low** | `modifyF` with `Kind<F, A>` | Libraries, custom effects, maximum flexibility\n\n### High-Level: Effect Path API\n\n\n // Clear, fluent, discoverable ValidationPath<List<String>, User> validated = validateName(name) .zipWith3Accum(validateAge(age), validateEmail(email), User::new);\n\n### Low-Level: modifyF with Applicative\n\n\n // 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()) );\n\nStart with the Effect Path API. Drop to `modifyF` when you need its power.\n\n* * *\n\n## Summary\n\nWe introduced the Effect Path API for effectful programming:\n\n 1. **Effect Path types** : `MaybePath`, `EitherPath`, `TryPath`, `ValidationPath`, `IOPath`, `VTaskPath`\n 2. **Railway model** : Values travel success/failure tracks with explicit error handling\n 3. **ValidationPath** : Accumulate all errors with `zipWithAccum` and `zipWith3Accum`\n 4. **VTaskPath** : Virtual thread concurrency with `Par` and `Scope` for parallel operations\n 5. **Bridge methods** : Connect Focus paths to Effect paths via `toMaybePath`, `toEitherPath`\n 6. **Type checking example** : Comprehensive error reporting with ValidationPath\n\n\n\nThe 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.\n\n* * *\n\n## Further Reading\n\n### Effect Systems and Functional Programming\n\n * **Scott Wlaschin,“Railway Oriented Programming” video and slides**: The visual explanation of error handling that inspired the railway metaphor.\n\n * **Conor McBride & Ross Paterson, “Applicative programming with effects”** (JFP, 2008): The paper that introduced `Applicative` as distinct from `Monad`, directly relevant to understanding why `Validated` accumulates errors.\n\n\n\n\n### Error Handling Patterns\n\n * **Handling Errors Without Exceptions** : Chapter 4 from “Functional Programming in Scala” (free excerpt).\n\n\n\n### Higher-Kinded Types and Functional Abstractions\n\n * **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.\n\n * **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.\n\n * **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.\n\n\n\n\n### Higher-Kinded-J\n\n * **Effect Path API Guide** : Railway-style error handling with MaybePath, EitherPath, ValidationPath, and VTaskPath.\n\n * **Path Factory** : Factory methods for creating Effect Paths.\n\n * **Semigroups** : Common semigroup implementations for error accumulation.\n\n * **Focus DSL Guide** : Fluent navigation with FocusPath, AffinePath, and TraversalPath.\n\n * **VTask and Structured Concurrency** : Virtual thread concurrency with Scope and Resource.\n\n * **Focus-Effect Integration** : Bridging the optics and effects domains with `toXxxPath()` and `focus()` methods.\n\n\n\n\n* * *\n\n### Next time\n\nWe’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.\n\nIn the final Part 6, we’ll step back and reflect on what we’ve built:\n\n * **The complete pipeline** : From source text through parsing, type checking, optimisation, and evaluation\n * **Design patterns** : Emergent patterns for effect-polymorphic code that work well\n * **Performance considerations** : When to use optics and when simpler approaches suffice\n * **Real-world applications** : Applying these techniques beyond expression languages\n\n\n\n* * *",
"title": "Functional Optics for Modern Java - Part 5",
"updatedAt": "2026-02-09T00:00:00.000Z"
}