Core Type Prisms: Safe Extraction and Pattern Matching

Prisms are optics that focus on one case of a sum type. They're perfect for safely extracting values from Maybe, Either, Validated, and Try without verbose pattern matching or null checks.
Think of a prism like a quality inspector at a factory sorting line. It can:
- Identify whether an item matches a specific case (
matches()) - Extract the value if it matches (
getOptional()) - Construct a new value of that case (
build())
This chapter shows you how to use prisms to work elegantly with Higher-Kinded-J's core types.
The Problem: Verbose Pattern Matching
Before we dive into prisms, let's see the traditional approach:
// Extracting from Maybe
Maybe<User> maybeUser = getUserById("u123");
if (maybeUser.isJust()) {
User user = maybeUser.get();
processUser(user);
} else {
handleMissingUser();
}
// Extracting from Either
Either<String, Order> result = createOrder(request);
if (result.isRight()) {
Order order = result.fold(err -> null, ord -> ord);
saveOrder(order);
} else {
String error = result.fold(err -> err, ord -> null);
logError(error);
}
// Extracting from Try
Try<Connection> tryConnection = connectToDatabase();
if (tryConnection.isSuccess()) {
Connection conn = tryConnection.fold(c -> c, ex -> null);
useConnection(conn);
} else {
Throwable error = tryConnection.fold(c -> null, ex -> ex);
handleError(error);
}
This code is repetitive, error-prone, and hard to compose with other operations.
// Extracting from Maybe
Prism<Maybe<User>, User> justPrism = Prisms.just();
getUserById("u123")
.flatMap(justPrism::getOptional)
.ifPresent(this::processUser);
// Extracting from Either
Prism<Either<String, Order>, Order> rightPrism = Prisms.right();
Prism<Either<String, Order>, String> leftPrism = Prisms.left();
Either<String, Order> result = createOrder(request);
rightPrism.getOptional(result).ifPresent(this::saveOrder);
leftPrism.getOptional(result).ifPresent(this::logError);
// Extracting from Try
Prism<Try<Connection>, Connection> successPrism = Prisms.success();
Prism<Try<Connection>, Throwable> failurePrism = Prisms.failure();
Try<Connection> tryConnection = connectToDatabase();
successPrism.getOptional(tryConnection).ifPresent(this::useConnection);
failurePrism.getOptional(tryConnection).ifPresent(this::handleError);
Clean, composable, and type-safe. The prisms handle the pattern matching internally.
Available Prisms
Higher-Kinded-J provides prisms for all core types in the Prisms utility class:
Prisms can also be used through the Fluent API for method chaining and better discoverability. For example, prism operations like getOptional and modify can be accessed through OpticOps methods for a more fluent interface.
Maybe Prisms
// Extract value from Just, returns empty Optional for Nothing
Prism<Maybe<A>, A> justPrism = Prisms.just();
Maybe<String> present = Maybe.just("Hello");
Maybe<String> absent = Maybe.nothing();
Optional<String> value = justPrism.getOptional(present); // Optional["Hello"]
Optional<String> empty = justPrism.getOptional(absent); // Optional.empty()
// Construct Maybe.just() from a value
Maybe<String> built = justPrism.build("World"); // Maybe.just("World")
// Check if it's a Just
boolean isJust = justPrism.matches(present); // true
boolean isNothing = justPrism.matches(absent); // false
Use Prisms.just() when:
- Extracting optional API response data
- Composing with other optics to navigate nested structures
- Converting
MaybetoOptionalfor interop with Java APIs - Filtering collections of
Maybevalues
Either Prisms
// Extract from Left and Right cases
Prism<Either<L, R>, L> leftPrism = Prisms.left();
Prism<Either<L, R>, R> rightPrism = Prisms.right();
Either<String, Integer> success = Either.right(42);
Either<String, Integer> failure = Either.left("Error");
// Extract success value
Optional<Integer> value = rightPrism.getOptional(success); // Optional[42]
Optional<Integer> noValue = rightPrism.getOptional(failure); // Optional.empty()
// Extract error value
Optional<String> noError = leftPrism.getOptional(success); // Optional.empty()
Optional<String> error = leftPrism.getOptional(failure); // Optional["Error"]
// Construct Either values
Either<String, Integer> newSuccess = rightPrism.build(100); // Either.right(100)
Either<String, Integer> newFailure = leftPrism.build("Oops"); // Either.left("Oops")
// Check which case
boolean isRight = rightPrism.matches(success); // true
boolean isLeft = leftPrism.matches(failure); // true
List<Either<String, User>> validationResults = validateUsers(requests);
Prism<Either<String, User>, User> validPrism = Prisms.right();
Prism<Either<String, User>, String> errorPrism = Prisms.left();
// Collect all successful users
List<User> validUsers = validationResults.stream()
.flatMap(result -> validPrism.getOptional(result).stream())
.toList();
// Collect all error messages
List<String> errors = validationResults.stream()
.flatMap(result -> errorPrism.getOptional(result).stream())
.toList();
System.out.println("Successfully validated: " + validUsers.size() + " users");
System.out.println("Validation errors: " + errors);
Validated Prisms
// Extract from Valid and Invalid cases
Prism<Validated<E, A>, A> validPrism = Prisms.valid();
Prism<Validated<E, A>, E> invalidPrism = Prisms.invalid();
Validated<String, Integer> valid = Validated.valid(30);
Validated<String, Integer> invalid = Validated.invalid("Age must be positive");
// Extract valid value
Optional<Integer> age = validPrism.getOptional(valid); // Optional[30]
Optional<Integer> noAge = validPrism.getOptional(invalid); // Optional.empty()
// Extract validation error
Optional<String> noError = invalidPrism.getOptional(valid); // Optional.empty()
Optional<String> error = invalidPrism.getOptional(invalid); // Optional["Age must be positive"]
// Construct Validated values
Validated<String, Integer> newValid = validPrism.build(25); // Validated.valid(25)
Validated<String, Integer> newInvalid = invalidPrism.build("Error"); // Validated.invalid("Error")
Validated and Either have similar prisms, but serve different purposes:
- Either prisms: Use for fail-fast validation (stop at first error)
- Validated prisms: Use with error accumulation (collect all errors)
The prisms themselves work identically—the difference is in how you combine multiple validations.
Try Prisms
// Extract from Success and Failure cases
Prism<Try<A>, A> successPrism = Prisms.success();
Prism<Try<A>, Throwable> failurePrism = Prisms.failure();
Try<Integer> success = Try.success(42);
Try<Integer> failure = Try.failure(new RuntimeException("Database error"));
// Extract success value
Optional<Integer> value = successPrism.getOptional(success); // Optional[42]
Optional<Integer> noValue = successPrism.getOptional(failure); // Optional.empty()
// Extract exception
Optional<Throwable> noEx = failurePrism.getOptional(success); // Optional.empty()
Optional<Throwable> ex = failurePrism.getOptional(failure); // Optional[RuntimeException]
// Construct Try values
Try<Integer> newSuccess = successPrism.build(100); // Try.success(100)
Try<Integer> newFailure = failurePrism.build(new IllegalStateException("Oops"));
List<Try<User>> dbResults = List.of(
Try.of(() -> fetchUser("u1")),
Try.of(() -> fetchUser("u2")),
Try.of(() -> fetchUser("u3"))
);
Prism<Try<User>, User> successPrism = Prisms.success();
Prism<Try<User>, Throwable> failurePrism = Prisms.failure();
// Count successful loads
long successCount = dbResults.stream()
.filter(successPrism::matches)
.count();
// Log all errors
dbResults.stream()
.flatMap(result -> failurePrism.getOptional(result).stream())
.forEach(error -> logger.error("Database error: {}", error.getMessage()));
System.out.println("Loaded " + successCount + "/" + dbResults.size() + " users");
Traversals for Core Types
Whilst prisms extract values, traversals modify values inside core types. Higher-Kinded-J provides traversal utilities for all core types:
Maybe Traversals
import org.higherkindedj.optics.util.MaybeTraversals;
Traversal<Maybe<String>, String> justTraversal = MaybeTraversals.just();
// Modify value inside Just
Maybe<String> original = Maybe.just("hello");
Maybe<String> modified = Traversals.modify(justTraversal, String::toUpperCase, original);
// Result: Maybe.just("HELLO")
// No effect on Nothing
Maybe<String> nothing = Maybe.nothing();
Maybe<String> unchanged = Traversals.modify(justTraversal, String::toUpperCase, nothing);
// Result: Maybe.nothing()
Either Traversals
import org.higherkindedj.optics.util.EitherTraversals;
Traversal<Either<String, Integer>, Integer> rightTraversal = EitherTraversals.right();
Traversal<Either<String, Integer>, String> leftTraversal = EitherTraversals.left();
// Modify Right value
Either<String, Integer> success = Either.right(100);
Either<String, Integer> doubled = Traversals.modify(rightTraversal, n -> n * 2, success);
// Result: Either.right(200)
// Enrich Left value (error enrichment)
Either<String, Integer> error = Either.left("Connection failed");
Either<String, Integer> enriched = Traversals.modify(
leftTraversal,
msg -> "[ERROR] " + msg,
error
);
// Result: Either.left("[ERROR] Connection failed")
The EitherTraversals.left() traversal is excellent for error enrichment—adding context or formatting to error messages without unwrapping the Either:
Either<String, Order> result = processOrder(request);
// Add request ID to all errors
Either<String, Order> enriched = Traversals.modify(
EitherTraversals.left(),
error -> String.format("[Request %s] %s", requestId, error),
result
);
Validated Traversals
import org.higherkindedj.optics.util.ValidatedTraversals;
Traversal<Validated<String, Integer>, Integer> validTraversal = ValidatedTraversals.valid();
Traversal<Validated<String, Integer>, String> invalidTraversal = ValidatedTraversals.invalid();
// Modify valid value
Validated<String, Integer> valid = Validated.valid(30);
Validated<String, Integer> incremented = Traversals.modify(validTraversal, age -> age + 1, valid);
// Result: Validated.valid(31)
// Modify error
Validated<String, Integer> invalid = Validated.invalid("Age required");
Validated<String, Integer> formatted = Traversals.modify(
invalidTraversal,
err -> "Validation Error: " + err,
invalid
);
// Result: Validated.invalid("Validation Error: Age required")
Try Traversals
import org.higherkindedj.optics.util.TryTraversals;
Traversal<Try<Integer>, Integer> successTraversal = TryTraversals.success();
Traversal<Try<Integer>, Throwable> failureTraversal = TryTraversals.failure();
// Modify success value
Try<Integer> success = Try.success(42);
Try<Integer> doubled = Traversals.modify(successTraversal, n -> n * 2, success);
// Result: Try.success(84)
// Wrap exceptions
Try<Integer> failure = Try.failure(new SQLException("Connection lost"));
Try<Integer> wrapped = Traversals.modify(
failureTraversal,
cause -> new DatabaseException("Database error", cause),
failure
);
// Result: Try.failure(DatabaseException wrapping SQLException)
Composition: The Real Power
Prisms compose seamlessly with lenses and other optics to navigate deeply nested structures:
@GenerateLenses
record ApiResponse(int statusCode, Maybe<Order> data, List<String> warnings) {}
@GenerateLenses
record Order(String orderId, Customer customer, List<OrderItem> items) {}
@GenerateLenses
record Customer(String customerId, String name, String email) {}
// Get customer email from API response (if present)
ApiResponse response = fetchOrder("ORD-123");
// Method 1: Using prism directly
Prism<Maybe<Order>, Order> justPrism = Prisms.just();
Optional<String> email = justPrism.getOptional(response.data())
.map(order -> order.customer().email());
// Method 2: Compose with lenses for a complete path
Lens<ApiResponse, Maybe<Order>> dataLens = ApiResponseLenses.data();
Traversal<Maybe<Order>, Order> orderTraversal = MaybeTraversals.just();
Lens<Order, Customer> customerLens = OrderLenses.customer();
Lens<Customer, String> emailLens = CustomerLenses.email();
// Full composition: ApiResponse -> Maybe<Order> -> Order -> Customer -> email
Traversal<ApiResponse, String> emailPath = dataLens
.andThen(orderTraversal)
.andThen(customerLens.asTraversal())
.andThen(emailLens.asTraversal());
List<String> emails = Traversals.toListOf(emailPath, response);
// Result: ["customer@example.com"] or [] if no order data
Processing Collections of Core Types
Prisms excel at filtering and extracting from collections of Maybe, Either, Validated, or Try:
Extracting Successes
List<Try<User>> dbResults = loadUsersFromDatabase(userIds);
Prism<Try<User>, User> successPrism = Prisms.success();
// Get all successfully loaded users
List<User> users = dbResults.stream()
.flatMap(result -> successPrism.getOptional(result).stream())
.toList();
Extracting Failures
List<Either<ValidationError, Order>> validations = validateOrders(requests);
Prism<Either<ValidationError, Order>, ValidationError> errorPrism = Prisms.left();
// Collect all validation errors
List<ValidationError> errors = validations.stream()
.flatMap(result -> errorPrism.getOptional(result).stream())
.toList();
if (!errors.isEmpty()) {
displayErrorsToUser(errors);
}
Counting Cases
List<Validated<List<String>, Product>> validations = validateProducts(products);
Prism<Validated<List<String>, Product>, Product> validPrism = Prisms.valid();
long validCount = validations.stream()
.filter(validPrism::matches)
.count();
System.out.println(validCount + "/" + validations.size() + " products valid");
Common Patterns
Pattern 1: Optional Chaining with Maybe
Instead of nested if (isJust()) checks:
// ❌ Traditional
Maybe<User> maybeUser = findUser(id);
if (maybeUser.isJust()) {
User user = maybeUser.get();
Maybe<Address> maybeAddress = user.getAddress();
if (maybeAddress.isJust()) {
Address address = maybeAddress.get();
processAddress(address);
}
}
// ✅ With prisms
Prism<Maybe<User>, User> justUserPrism = Prisms.just();
Prism<Maybe<Address>, Address> justAddressPrism = Prisms.just();
findUser(id)
.flatMap(justUserPrism::getOptional)
.flatMap(user -> user.getAddress())
.flatMap(justAddressPrism::getOptional)
.ifPresent(this::processAddress);
Pattern 2: Error Handling with Either
Extracting specific error types:
sealed interface AppError permits ValidationError, DatabaseError, NetworkError {}
Either<AppError, User> result = createUser(request);
Prism<Either<AppError, User>, User> successPrism = Prisms.right();
Prism<Either<AppError, User>, AppError> errorPrism = Prisms.left();
// Handle success
successPrism.getOptional(result).ifPresent(user -> {
logger.info("User created: {}", user.id());
sendWelcomeEmail(user);
});
// Handle errors
errorPrism.getOptional(result).ifPresent(error -> {
switch (error) {
case ValidationError ve -> displayFormErrors(ve);
case DatabaseError de -> retryOrFallback(de);
case NetworkError ne -> scheduleRetry(ne);
}
});
Pattern 3: Exception Recovery with Try
Try<Config> configResult = Try.of(() -> loadConfig(configPath));
Prism<Try<Config>, Config> successPrism = Prisms.success();
Prism<Try<Config>, Throwable> failurePrism = Prisms.failure();
// Use config if loaded successfully
Config config = successPrism.getOptional(configResult)
.orElseGet(() -> {
// Log the failure
failurePrism.getOptional(configResult).ifPresent(error ->
logger.error("Failed to load config", error)
);
// Return default config
return Config.defaults();
});
Before/After Comparison
Let's see a complete real-world scenario comparing traditional approaches with prisms:
Scenario: Processing a batch of API responses, each containing optional user data.
public List<String> extractUserEmails(List<ApiResponse<User>> responses) {
List<String> emails = new ArrayList<>();
for (ApiResponse<User> response : responses) {
if (response.statusCode() == 200) {
Maybe<User> data = response.data();
if (data.isJust()) {
User user = data.get();
if (user.email() != null) {
emails.add(user.email());
}
}
}
}
return emails;
}
Problems:
- Deeply nested conditionals
- Manual null checking
- Imperative style with mutable list
- Easy to introduce bugs
public List<String> extractUserEmails(List<ApiResponse<User>> responses) {
Prism<Maybe<User>, User> justPrism = Prisms.just();
return responses.stream()
.filter(r -> r.statusCode() == 200)
.flatMap(r -> justPrism.getOptional(r.data()).stream())
.map(User::email)
.filter(Objects::nonNull)
.toList();
}
Benefits:
- Flat, readable pipeline
- Prism handles the Maybe extraction
- Declarative, functional style
- Harder to introduce bugs
Best Practices
Use prisms when:
- Extracting values from core types
- Pattern matching on sum types
- Composing with other optics for deep navigation
- Processing collections of core types
Use traversals when:
- Modifying values inside core types
- Applying transformations conditionally
- Error enrichment or exception wrapping
Remember that prism.getOptional() returns Java's Optional, not Maybe:
Prism<Maybe<String>, String> justPrism = Prisms.just();
Maybe<String> maybeValue = Maybe.just("Hello");
// Returns Optional, not Maybe
Optional<String> value = justPrism.getOptional(maybeValue);
// Convert back to Maybe if needed
Maybe<String> backToMaybe = value
.map(Maybe::just)
.orElse(Maybe.nothing());
Working Example
For a complete, runnable demonstration of all these patterns, see:
This example demonstrates:
- All core type prisms (Maybe, Either, Validated, Try)
- All core type traversals
- Composition with lenses
- Processing collections
- Before/after comparisons
- Real-world API response processing
Summary
Core type prisms provide:
🎯 Safe Extraction — Extract values from Maybe, Either, Validated, and Try without null checks or verbose pattern matching
🔍 Pattern Matching — Use matches() to check cases, getOptional() to extract values
🔄 Composability — Combine with lenses and traversals for deep navigation
📊 Collection Processing — Filter, extract, and count different cases in collections
🛡️ Type Safety — The compiler ensures you handle all cases correctly
Next Steps
Now that you understand core type prisms, learn how to enhance lens operations with validation and error handling:
Next: Lens Extensions: Validated Field Operations
Or return to the overview: