Effect API Journey: Cheatsheet

A single-page reference for the Effect Path API. Two columns: how we wrote it before, how we write it now.

Scope

  • All Path factory methods and the four core operations (map, via, recovery, zipWith)
  • Path-native resilience combinators (withRetry, withTimeout, withCircuitBreaker, withBulkhead)
  • ForPath comprehension shape and tuple-binding semantics
  • Effect Contexts: ErrorContext, ConfigContext, MutableContext
  • Service-integration patterns (@GeneratePathBridge)
  • Focus-Effect bridge basics

Pick the right Path type

The shape we haveThe Path we wantFactory
A value that may be nullMaybePath<A>Path.maybe(nullable)
A value we know is presentMaybePath<A>Path.just(value)
Explicit absenceMaybePath<A>Path.nothing()
A value or a typed errorEitherPath<E, A>Path.right(v) / Path.left(e) / Path.either(either)
A Supplier that may throwTryPath<A>Path.tryOf(() -> ...)
A side-effecting computation, deferredIOPath<A>Path.io(() -> ...)
A virtual-thread async computationVTaskPath<A>Path.vtask(() -> ...)
A virtual-thread async computation with a typed errorVResultPath<E, A>Path.vresultDefer(() -> ...) / Path.vresult(vtaskOfEither)
An existing Optional<A>OptionalPath<A>Path.optional(opt)

Core operations (work on every Path)

PatternImperative JavaHigher-Kinded-J
Transform the success valueopt.map(f) / future.thenApply(f)path.map(f)
Chain a step that itself returns a Pathopt.flatMap(f) / future.thenCompose(f)path.via(f) (flatMap is an alias)
Replace error with a valuetry { ... } catch (E e) { return default; }path.recover(err -> default)
Replace error with another pathtry { ... } catch (E e) { return retry(); }path.recoverWith(err -> alt)
Use an alternative when this failsOptional.or(() -> alt)path.orElse(() -> alt)
Translate the error typemanual mappath.mapError(e -> e2)
Combine two independent pathsCompletableFuture.allOf then unpackpathA.zipWith(pathB, combiner)

Resilience combinators

Resilience wraps a computation. The lazy carriers (IOPath, VTaskPath, VResultPath) chain the combinators as instance methods; the eager EitherPath offers the same vocabulary as static methods taking the step as a Supplier.

PatternLazy carriers (instance)Eager EitherPath (static)
Retry on thrown exceptionspath.withRetry(policy)EitherPath.withRetry(() -> step(), policy)
Railway-aware retry (typed errors opt in)vresultPath.withRetry(retryOn, policy)EitherPath.withRetry(() -> step(), retryOn, policy)
Time budgetpath.withTimeout(duration) / vresultPath.withTimeout(duration, onTimeout)EitherPath.withTimeout(() -> step(), duration, onTimeout)
Circuit breakerpath.withCircuitBreaker(breaker) / vresultPath.withCircuitBreaker(breaker, onOpen)EitherPath.withCircuitBreaker(() -> step(), breaker[, onOpen])
Bulkheadpath.withBulkhead(bulkhead) / vresultPath.withBulkhead(bulkhead, onFull)EitherPath.withBulkhead(() -> step(), bulkhead[, onFull])

On the typed carriers a business Left is a value, not a fault: never retried, never counted by a circuit breaker; the onTimeout/onOpen/onFull overloads land rejections as typed Lefts. Never wrap a non-idempotent step (payment) in retry.


map vs via: the canonical decision

Function shapeReach forResult shape
A -> B (plain value)mapPath<B>
A -> Path<B> (wrapped value)via (or flatMap)Path<B> (flattened)
A -> Path<B> but we used map(compile error or worse)Path<Path<B>> (silently nested)

When in doubt, follow the type: if the lambda body returns a Path, the call is via.


ForPath at a glance

ForPath.from(initialPath)              // start with a Path
  .from(value -> dependentPath(value)) // bind another Path step
  .let(value -> pureFunction(value))   // bind a pure value (no new effect)
  .yield((step1, step2, step3) -> ...) // combine all bound values
  • Each .from(...) lambda after the first receives a tuple of previously bound values; reach in with t._1(), t._2(), ...
  • .yield(...) is the only place where every binding is in scope by name.
  • Short-circuits on the first failing step (Nothing / Left / Failure), exactly like a plain chain of vias.

When to reach for ForPath: three or more dependent steps, or when intermediate values need to be referenced from later steps.


Effect Contexts

ContextWrapsUse for
ErrorContext<?, E, A>EitherT (typed error + IO)Async work that may fail with a domain error
ConfigContext<?, R, A>ReaderTThreading configuration through a workflow without DI
MutableContext<?, S, A>StateTWorkflow-local state without ThreadLocal or mutable fields

All three carry the same map / via / recover surface as the Path API; the suffix differs only in how we run the workflow at the boundary:

errorCtx.runIO().unsafeRun();         // -> Either<E, A>
configCtx.runWithSync(config);        // -> A
mutableCtx.runWith(initial)           // -> StateTuple<S, A>
         .unsafeRun();

Service integration

@GeneratePathBridge
public interface UserService {
    @PathVia
    Optional<User> findById(Long id);

    @PathVia
    Either<Error, User> createUser(CreateUserRequest req);
}

The annotation processor generates UserServicePaths with:

Original returnGenerated wrapper
Optional<T>OptionalPath<T>
Maybe<T>MaybePath<T>
Either<E, T>EitherPath<E, T>
Try<T>TryPath<T>
Validated<E, T>ValidationPath<E, T> (adds a Semigroup<E> parameter)
IO<T>IOPath<T>

The wrapper is chosen from the declared return type alone; @PathVia takes name, doc and composable, none of which select it. A return type outside this table is refused with "Unsupported return type for @PathVia"CompletableFuture among them, so wrap it in a VTaskPath or CompletableFuturePath by hand.

The wrapper is exactly the one-liner shown in Tutorial 02 Exercise 6, generated so we never have to write it.

Type parameters carry through. A generic service gives a generic bridge — Repo<T> yields RepoPaths<T> wrapping a Repo<T>, bounds included — and a generic @PathVia method keeps its own parameters on the bridge method:

@GeneratePathBridge
public interface Repo<T extends Comparable<T>> {
    @PathVia
    <R> Optional<R> convert(T from, Class<R> to);
}

// generated
public final class RepoPaths<T extends Comparable<T>> {
    public <R> OptionalPath<R> convert(T from, Class<R> to) { ... }
}

Inherited @PathVia methods are bridged too, read under the instantiation the annotated interface gives them: StringStore extends Store<String> gets Store's methods with T already String. An overridden method is bridged once, and so is a method that two unrelated superinterfaces both declare. The annotation itself is not inherited, though, so a method that overrides an annotated one hides it unless it is annotated too; an interface left with no @PathVia method anywhere draws a warning rather than a silently empty bridge. A throws clause and a varargs parameter both carry across unchanged.

The bridge is a file you never wrote and cannot edit, so a handful of shapes the language accepts are refused at your own declaration instead: a raw type anywhere in the signature, a Validated whose error type is a wildcard, a method type parameter that hides one of the interface's, a static or private method, and, under targetPackage, anything the target package cannot see. Common Compiler Errors quotes each message with its cause and remedy.


Focus-Effect bridge

EitherPath<Error, User>
  .focus(addressPath)        // narrow to the Address inside the User
  .focus(cityPath)            // narrow further to the city String
  .map(String::toUpperCase);  // transform the focused field

Errors propagate through focus(...) unchanged. For more complex navigation use ForPath with .focus(...) (Tutorial 14 of the Optics journey covers the full bridge).


The boundary rule

unsafeRun() (and equivalents like runWithSync) belong at the edge of the program. Build the entire workflow as a value, then run once.

// Right
ErrorContext<?, E, A> workflow =
    step1().via(this::step2).via(this::step3).map(this::finalize);
return workflow.runIO().unsafeRun();

// Wrong
A intermediate = step1().runIO().unsafeRun();   // executes too early
return step2(intermediate).runIO().unsafeRun(); // runs the IO twice

Where this lands in One Line, Six Layers

The Effect API is the layer that lets us write the One Line, Six Layers expression at all:

   repo.find(id)              .toEitherPath()      .focus().attributes().at(key)
   └── Effect Path ───────────┤                    └── Optic ─ via Focus DSL ───┐
       MaybePath, EitherPath, │                                                 │
       TryPath, IOPath, ...   │                                                 │
                              │                                                 │
                              └── Tutorial 01: every Path conversion lives in   │
                                  this one method shape                         │
                                                                                │
   .modify(spec::validateAndCoerce)             .flatMap(repo::save);           │
   └── map (under the optic) ────┐              └── via / flatMap ──────────────┘
       Tutorial 01 (map)         │                  Tutorial 01 (via)
                                │
                                └── ForPath / Contexts (Tutorial 02) flatten
                                    multi-step versions of this shape

See also: Effect Path Overview · ForPath Comprehension · Effect Contexts · One Line, Six Layers