Prisms: A Practical Guide
Working with Sum Types
- How to safely work with sum types and sealed interfaces
- Using
@GeneratePrismsto create type-safe variant accessors - The difference between
getOptionalandbuildoperations - Composing prisms with other optics for deep conditional access
- Handling optional data extraction without
instanceofchains - When to use prisms vs pattern matching vs traditional type checking
The previous guide demonstrated how a Lens gives us a powerful, composable way to work with "has-a" relationships: a field that is guaranteed to exist within a record.
But what happens when the data doesn't have a guaranteed structure? What if a value can be one of several different types? This is the domain of "is-a" relationships, or sum types, commonly modelled in Java using sealed interface or enum.
For this, we need a different kind of optic: the Prism.
The Scenario: Working with JSON-like Data
A Lens is like a sniper rifle, targeting a single, known field. A Prism is like a safe-cracker's tool; it attempts to open a specific "lock" (a particular type) and only succeeds if it has the right key.
Consider a common scenario: modelling a JSON structure. A value can be a string, a number, a boolean, or a nested object.
The Data Model: We can represent this with a sealed interface.
import org.higherkindedj.optics.annotations.GeneratePrisms;
import org.higherkindedj.optics.annotations.GenerateLenses;
import java.util.Map;
@GeneratePrisms // Generates Prisms for each case of the sealed interface
public sealed interface JsonValue {}
public record JsonString(String value) implements JsonValue {}
public record JsonNumber(double value) implements JsonValue {}
public record JsonBoolean(boolean value) implements JsonValue {}
@GenerateLenses // We can still use Lenses on the product types within the sum type
public record JsonObject(Map<String, JsonValue> fields) implements JsonValue {}
Our Goal: We need to safely access and update the value of a JsonString that is deeply nested within another JsonObject. An instanceof and casting approach would be unsafe and verbose. A Lens won't work because a JsonValue might be a JsonNumber, not the JsonObject we expect.
Think of Prisms Like...
- A type-safe filter: Only "lets through" values that match a specific shape
- A safe cast: Like
instanceof+ cast, but functional and composable - A conditional lens: Works like a lens, but might return empty if the type doesn't match
- A pattern matcher: Focuses on one specific case of a sum type
A Step-by-Step Walkthrough
Step 1: Generating the Prisms
Just as with lenses, we annotate our sealed interface with @GeneratePrisms. This automatically creates a companion class (e.g., JsonValuePrisms) with a Prism for each permitted subtype.
// Generated automatically:
// JsonValuePrisms.jsonString() -> Prism<JsonValue, JsonString>
// JsonValuePrisms.jsonNumber() -> Prism<JsonValue, JsonNumber>
// JsonValuePrisms.jsonBoolean() -> Prism<JsonValue, JsonBoolean>
// JsonValuePrisms.jsonObject() -> Prism<JsonValue, JsonObject>
Customising the Generated Package
By default, generated classes are placed in the same package as the annotated type. You can specify a different package using the targetPackage attribute:
// Generated class will be placed in org.example.generated.optics
@GeneratePrisms(targetPackage = "org.example.generated.optics")
public sealed interface JsonValue {}
This is useful when you need to avoid name collisions or organise generated code separately.
Step 2: The Core Prism Operations
A Prism is defined by two unique, failable operations:
getOptional(source): Attempts to focus on the target. It returns anOptionalwhich is non-empty only if thesourcematches the Prism's specific case. This is the safe alternative to aninstanceofcheck and cast.build(value): Constructs the top-level type from a part. This is the reverse operation, used to wrap a value back into its specific case (e.g., taking aStringand building aJsonString).
Prism<JsonValue, JsonString> jsonStringPrism = JsonValuePrisms.jsonString();
// --- Using getOptional (the safe "cast") ---
Optional<JsonString> result1 = jsonStringPrism.getOptional(new JsonString("hello"));
// -> Optional.of(JsonString("hello"))
Optional<JsonString> result2 = jsonStringPrism.getOptional(new JsonNumber(123));
// -> Optional.empty()
// --- Using build (construct the sum type from a part) ---
JsonValue result3 = jsonStringPrism.build(new JsonString("world"));
// -> JsonString("world") (as JsonValue)
Step 3: Composing Prisms for Deep Access
The true power is composing Prisms with other optics. When a composition might fail (any time a Prism is involved), the result is an Affine (or Traversal when combining with traversals).
higher-kinded-j provides direct composition methods that automatically return the correct type:
Lens.andThen(Prism)returnsAffinePrism.andThen(Lens)returnsAffinePrism.andThen(Prism)returnsPrismAffine.andThen(Affine)returnsAffine
See Composition Rules for the complete reference.
// Create all the optics we need
Prism<JsonValue, JsonObject> jsonObjectPrism = JsonValuePrisms.jsonObject();
Prism<JsonValue, JsonString> jsonStringPrism = JsonValuePrisms.jsonString();
Lens<JsonObject, Map<String, JsonValue>> fieldsLens = JsonObjectLenses.fields();
Lens<JsonString, String> valueLens = JsonStringLenses.value();
// Direct composition: Prism >>> Lens = Affine
Affine<JsonValue, String> jsonStringValue =
jsonStringPrism.andThen(valueLens);
// The composed optic: safely navigate from JsonObject -> userLogin field -> name field -> string value
Traversal<JsonObject, String> userNameTraversal =
fieldsLens.asTraversal() // JsonObject -> Map<String, JsonValue>
.andThen(Traversals.forMap("userLogin")) // -> JsonValue (if "userLogin" key exists)
.andThen(jsonObjectPrism.asTraversal()) // -> JsonObject (if it's an object)
.andThen(fieldsLens.asTraversal()) // -> Map<String, JsonValue>
.andThen(Traversals.forMap("name")) // -> JsonValue (if "name" key exists)
.andThen(jsonStringValue.asTraversal()); // -> String (if it's a string)
This composed Traversal now represents a safe, deep path that will only succeed if every step in the chain matches.
When to Use Prisms vs Other Approaches
Use Prisms When:
- Type-safe variant handling - Working with
sealed interfaceorenumcases - Optional data extraction - You need to safely "try" to get a specific type
- Composable type checking - Building reusable type-safe paths
- Functional pattern matching - Avoiding
instanceofchains
// Perfect for safe type extraction
Optional<String> errorMessage = DomainErrorPrisms.validationError()
.andThen(ValidationErrorLenses.message())
.getOptional(someError);
Use Traditional instanceof When:
- One-off type checks - Not building reusable logic
- Imperative control flow - You need if/else branching
- Performance critical paths - Minimal abstraction overhead needed
// Sometimes instanceof is clearer for simple cases
if (jsonValue instanceof JsonString jsonStr) {
return jsonStr.value().toUpperCase();
}
Use Pattern Matching When:
- Exhaustive case handling - You need to handle all variants
- Complex extraction logic - Multiple levels of pattern matching
- Modern codebases - Using recent Java features
// Pattern matching for comprehensive handling
return switch (jsonValue) {
case JsonString(var str) -> str.toUpperCase();
case JsonNumber(var num) -> String.valueOf(num);
case JsonBoolean(var bool) -> String.valueOf(bool);
case JsonObject(var fields) -> "Object with " + fields.size() + " fields";
};
Common Pitfalls
Don't Do This:
// Unsafe: Assuming the cast will succeed
JsonString jsonStr = (JsonString) jsonValue; // Can throw ClassCastException!
// Verbose: Repeated instanceof checks
if (jsonValue instanceof JsonObject obj1) {
var userValue = obj1.fields().get("userLogin");
if (userValue instanceof JsonObject obj2) {
var nameValue = obj2.fields().get("name");
if (nameValue instanceof JsonString str) {
return str.value().toUpperCase();
}
}
}
// Inefficient: Creating prisms repeatedly
var name1 = JsonValuePrisms.jsonString().getOptional(value1);
var name2 = JsonValuePrisms.jsonString().getOptional(value2);
var name3 = JsonValuePrisms.jsonString().getOptional(value3);
Do This Instead:
// Safe: Use prism's getOptional
Optional<JsonString> maybeJsonStr = JsonValuePrisms.jsonString().getOptional(jsonValue);
// Composable: Build reusable safe paths
var userNamePath = JsonValuePrisms.jsonObject()
.andThen(JsonObjectLenses.fields())
.andThen(mapValue("userLogin"))
.andThen(JsonValuePrisms.jsonObject())
// ... continue composition
// Efficient: Reuse prisms and composed paths
var stringPrism = JsonValuePrisms.jsonString();
var name1 = stringPrism.getOptional(value1);
var name2 = stringPrism.getOptional(value2);
var name3 = stringPrism.getOptional(value3);
Performance Notes
Prisms are optimised for type safety and composability:
- Fast type checking: Prisms use
instanceofunder the hood, which is optimised by the JVM - Lazy evaluation: Composed prisms only perform checks when needed
- Memory efficient: No boxing or wrapper allocation for failed matches
- Composable: Complex type-safe paths can be built once and reused
Best Practice: For frequently used prism combinations, create them once and store as constants:
public class JsonOptics {
private static final Lens<JsonObject, Map<String, JsonValue>> fieldsLens =
JsonObjectLenses.fields();
public static final Prism<JsonValue, JsonString> STRING =
JsonValuePrisms.jsonString();
public static final Traversal<JsonValue, String> STRING_VALUE =
STRING.andThen(JsonStringLenses.value());
public static final Traversal<JsonObject, String> USER_NAME =
fieldsLens.asTraversal()
.andThen(Traversals.forMap("userLogin"))
.andThen(JsonValuePrisms.jsonObject().asTraversal())
.andThen(fieldsLens.asTraversal())
.andThen(Traversals.forMap("name"))
.andThen(STRING.asTraversal())
.andThen(JsonStringLenses.value().asTraversal());
}
Real-World Example: API Response Handling
Here's a practical example of using prisms to handle different API response types safely:
@GeneratePrisms
public sealed interface ApiResponse {}
public record SuccessResponse(String data, int statusCode) implements ApiResponse {}
public record ErrorResponse(String message, String errorCode) implements ApiResponse {}
public record TimeoutResponse(long timeoutMs) implements ApiResponse {}
public class ApiHandler {
// Reusable prisms for different response types
private static final Prism<ApiResponse, SuccessResponse> SUCCESS =
ApiResponsePrisms.successResponse();
private static final Prism<ApiResponse, ErrorResponse> ERROR =
ApiResponsePrisms.errorResponse();
private static final Prism<ApiResponse, TimeoutResponse> TIMEOUT =
ApiResponsePrisms.timeoutResponse();
public String handleResponse(ApiResponse response) {
// Type-safe extraction and handling
return SUCCESS.getOptional(response)
.map(success -> "Success: " + success.data())
.or(() -> ERROR.getOptional(response)
.map(error -> "Error " + error.errorCode() + ": " + error.message()))
.or(() -> TIMEOUT.getOptional(response)
.map(timeout -> "Request timed out after " + timeout.timeoutMs() + "ms"))
.orElse("Unknown response type");
}
// Use prisms for conditional processing
public boolean isRetryable(ApiResponse response) {
return ERROR.getOptional(response)
.map(error -> "RATE_LIMIT".equals(error.errorCode()) || "TEMPORARY".equals(error.errorCode()))
.or(() -> TIMEOUT.getOptional(response).map(t -> true))
.orElse(false);
}
}
Complete, Runnable Example
This example puts it all together, showing how to use the composed Traversal to perform a safe update.
package org.higherkindedj.example.prism;
import org.higherkindedj.optics.Lens;
import org.higherkindedj.optics.Prism;
import org.higherkindedj.optics.Traversal;
import org.higherkindedj.optics.annotations.GenerateLenses;
import org.higherkindedj.optics.annotations.GeneratePrisms;
import org.higherkindedj.optics.util.Traversals;
import java.util.*;
public class PrismUsageExample {
// 1. Define the nested data model with sum types.
@GeneratePrisms
public sealed interface JsonValue {}
public record JsonString(String value) implements JsonValue {}
public record JsonNumber(double value) implements JsonValue {}
public record JsonBoolean(boolean value) implements JsonValue {}
@GenerateLenses
public record JsonObject(Map<String, JsonValue> fields) implements JsonValue {}
public static void main(String[] args) {
// 2. Create the initial nested structure.
Map<String, JsonValue> userData = Map.of(
"userLogin", new JsonObject(Map.of(
"name", new JsonString("Alice"),
"age", new JsonNumber(30),
"active", new JsonBoolean(true)
)),
"metadata", new JsonObject(Map.of(
"version", new JsonString("1.0")
))
);
var data = new JsonObject(userData);
System.out.println("Original Data: " + data);
System.out.println("------------------------------------------");
// 3. Get the generated and manually created optics.
Prism<JsonValue, JsonObject> jsonObjectPrism = JsonValuePrisms.jsonObject();
Prism<JsonValue, JsonString> jsonStringPrism = JsonValuePrisms.jsonString();
Lens<JsonObject, Map<String, JsonValue>> fieldsLens = JsonObjectLenses.fields();
Lens<JsonString, String> jsonStringValueLens = Lens.of(JsonString::value, (js, s) -> new JsonString(s));
// 4. Demonstrate individual prism operations
System.out.println("--- Individual Prism Operations ---");
// Safe type extraction
JsonValue userValue = data.fields().get("userLogin");
Optional<JsonObject> userObject = jsonObjectPrism.getOptional(userValue);
System.out.println("User object: " + userObject);
// Attempting to extract wrong type
JsonValue nameValue = ((JsonObject) userValue).fields().get("name");
Optional<JsonNumber> nameAsNumber = JsonValuePrisms.jsonNumber().getOptional(nameValue);
System.out.println("Name as number (should be empty): " + nameAsNumber);
// Building new values
JsonValue newString = jsonStringPrism.build(new JsonString("Bob"));
System.out.println("Built new string: " + newString);
System.out.println("------------------------------------------");
// 5. Compose the full traversal.
Traversal<JsonObject, String> userToJsonName =
fieldsLens.asTraversal()
.andThen(Traversals.forMap("userLogin"))
.andThen(jsonObjectPrism.asTraversal())
.andThen(fieldsLens.asTraversal())
.andThen(Traversals.forMap("name"))
.andThen(jsonStringPrism.asTraversal())
.andThen(jsonStringValueLens.asTraversal());
// 6. Use the composed traversal to perform safe updates
System.out.println("--- Composed Traversal Operations ---");
JsonObject updatedData = Traversals.modify(userToJsonName, String::toUpperCase, data);
System.out.println("After safe `modify`: " + updatedData);
// 7. Demonstrate that the traversal safely handles missing paths
var dataWithoutUser = new JsonObject(Map.of("metadata", new JsonString("test")));
JsonObject safeUpdate = Traversals.modify(userToJsonName, String::toUpperCase, dataWithoutUser);
System.out.println("Safe update on missing path: " + safeUpdate);
System.out.println("Original is unchanged: " + data);
System.out.println("------------------------------------------");
// 8. Demonstrate error-resistant operations
System.out.println("--- Error-Resistant Operations ---");
// Get all string values safely
List<String> allStrings = List.of(
new JsonString("hello"),
new JsonNumber(42),
new JsonString("world"),
new JsonBoolean(true)
).stream()
.map(jsonStringPrism::getOptional)
.filter(Optional::isPresent)
.map(Optional::get)
.map(JsonString::value)
.toList();
System.out.println("Extracted strings only: " + allStrings);
}
}
Expected Output:
Original Data: JsonObject[fields={userLogin=JsonObject[fields={name=JsonString[value=Alice], age=JsonNumber[value=30.0], active=JsonBoolean[value=true]}], metadata=JsonObject[fields={version=JsonString[value=1.0]}]}]
------------------------------------------
--- Individual Prism Operations ---
User object: Optional[JsonObject[fields={name=JsonString[value=Alice], age=JsonNumber[value=30.0], active=JsonBoolean[value=true]}]]
Name as number (should be empty): Optional.empty
Built new string: JsonString[value=Bob]
------------------------------------------
--- Composed Traversal Operations ---
After safe `modify`: JsonObject[fields={userLogin=JsonObject[fields={name=JsonString[value=ALICE], age=JsonNumber[value=30.0], active=JsonBoolean[value=true]}], metadata=JsonObject[fields={version=JsonString[value=1.0]}]}]
Safe update on missing path: JsonObject[fields={metadata=JsonString[value=test]}]
Original is unchanged: JsonObject[fields={userLogin=JsonObject[fields={name=JsonString[value=Alice], age=JsonNumber[value=30.0], active=JsonBoolean[value=true]}], metadata=JsonObject[fields={version=JsonString[value=1.0]}]}]
------------------------------------------
--- Error-Resistant Operations ---
Extracted strings only: [hello, world]
- A prism is a failable focus on one variant:
getOptionalis the safe cast,buildthe constructor back into the sum type Lenshandles the "what",Prismthe "what if": a prism is the type-safeinstanceofplus cast, composable and reusable- Composition tells the truth: a prism in the chain makes the result an
AffineorTraversal, so the possibility of no match is visible in the type - Reuse beats repetition: build prisms and composed paths once and store them as constants; changes to the data model surface as compile errors at the optic, never as runtime surprises
- Prism Toolkit: the full convenience-method catalogue and the
Prismsutility factory methods forOptional,Either,Maybe,Try, and list decomposition - Validated Prisms: when the no needs to carry located, accumulated reasons (a validated boundary)
Once you're comfortable with these prism fundamentals, explore Advanced Prism Patterns for production-ready patterns including:
- Configuration management with layered prism composition
- API response handling with type-safe error recovery
- Data validation pipelines and event processing systems
- State machine implementations and plugin architectures
- Performance optimisation and testing strategies
Prisms integrate with For comprehensions via the match() operation, which provides prism-based pattern matching with short-circuit semantics. When the prism match fails, the computation short-circuits using the monad's zero value (empty list, Nothing, etc.). See For Comprehensions: Pattern Matching with match().
Practice prism basics in Tutorial 03: Prism Basics (9 exercises, ~12 minutes).
- Monocle: Scala Optics Library - Production-ready Scala optics with extensive examples
- Haskell Lens: Canonical Reference - The original comprehensive optics library
- Lens Tutorial: A Little Lens Starter Tutorial - Beginner-friendly introduction
Previous: Lenses: Working with Product Types Next: Prism Toolkit