functional-reactive
Made in the European Union

Pattern matching with Case

Replace if/else cascades and switch ladders with declarative Case branches that each yield a Result.

May 17, 2026

Java’s three branching constructs โ€” if/else, the ternary ?:, and switch โ€” share a quiet flaw: they describe how to test, not what the branches are. They also tightly couple the order of evaluation to the source layout. Case flips this around: each branch is a self-contained pair of condition and result, and the API explicitly chooses the first one that fires.

The problem with if/else ladders

String grade;
if (score >= 90)      grade = "A";
else if (score >= 80) grade = "B";
else if (score >= 70) grade = "C";
else                  grade = "F";

You can read this top-to-bottom only because the variable assignment is on every line. Add a fourth branch and you’d better remember to update the else. Worse, grade is mutable for the span of that block โ€” there’s no compile-time guarantee that every path assigns it.

The ternary form is no better:

String grade = (score >= 90) ? "A"
             : (score >= 80) ? "B"
             : (score >= 70) ? "C"
             :                 "F";

Read it once, fine. Maintain it over time and you’ll be counting parens.

The Case approach

Case makes every branch a value built by matchCase(condition, result), and the default is a separate DefaultCase constructed by the no-condition matchCase(result):

import static com.svenruppert.functional.matcher.Case.match;
import static com.svenruppert.functional.matcher.Case.matchCase;
import static com.svenruppert.functional.model.Result.success;

Result<String> grade = match(
    matchCase(()             -> success("F")),               // default
    matchCase(() -> score >= 90, () -> success("A")),
    matchCase(() -> score >= 80, () -> success("B")),
    matchCase(() -> score >= 70, () -> success("C"))
);

grade.ifPresentOrElse(System.out::println, System.err::println);

Three properties worth noticing:

  1. Branches are values. You can store them, pass them, generate them โ€” they’re just Supplier<Boolean> + Supplier<Result<T>> pairs.
  2. Evaluation is explicit. match walks the branches in the order you wrote them, returning the first whose condition holds. No fall-through, no else.
  3. Every branch yields a Result. Even the “no branch matched” case is handled โ€” the DefaultCase provides the fallback.

Lazy conditions, lazy values

Both halves of a matchCase are Suppliers. That means:

  • Conditions are not evaluated until match reaches them โ€” earlier matches short-circuit.
  • Results are not constructed until their branch is chosen โ€” expensive payloads only build for the branch you actually pick.
Result<Heavy> r = match(
    matchCase(() -> success(buildDefault())),
    matchCase(() -> mode == FAST,   () -> success(buildFast())),
    matchCase(() -> mode == ACCURATE, () -> success(buildAccurate()))
);

buildAccurate() and buildDefault() only run if their branches win.

Functions as branch values

Because each branch returns a Result<T> for any T, T can be a Function. That gives you a Case-as-strategy-selector:

Result<Function<Integer, Integer>> op = match(
    matchCase(() -> success(v -> v)),                                 // identity
    matchCase(() -> input == ADD,      () -> success(v -> v + 1)),
    matchCase(() -> input == DOUBLE,   () -> success(v -> v * 2)),
    matchCase(() -> input == NEGATIVE, () -> success(v -> -v))
);

op.map(f -> f.apply(10))
  .ifPresent(System.out::println);

Three lines that would be a switch returning a method reference โ€” but here every branch is a value, easy to test in isolation.

Failure as just another branch

The default branch can be a Failure:

Result<String> r = match(
    matchCase(()             -> Result.failure("no branch matched")),
    matchCase(() -> input == "A", () -> success("hello A")),
    matchCase(() -> input == "B", () -> success("hello B"))
);

r.ifPresentOrElse(System.out::println, err -> log.warn(err));

No exception, no sentinel value โ€” the absence of a match becomes a regular Result.failure your caller can handle alongside any other failure.

When not to use Case

Case shines when branches need their own predicates and produce values. Stick with:

  • switch for cheap enum/int dispatch with no branch-internal logic.
  • if/else when there’s no value to produce.
  • Map.get when you really have a lookup table, not a decision tree.

Recap

  • Each branch is a matchCase(condition, result) value โ€” composable and testable.
  • match(default, branches...) evaluates branches in order and returns the first match.
  • Conditions and results are Suppliers โ€” lazily evaluated.
  • Failure is just another Result โ€” no exceptions, no nulls.

Next