Optics: Lens & Prism Journey

What We'll Learn

  • Accessing and updating fields in immutable records with Lenses
  • Composing lenses for deep nested access
  • Working with sum types (sealed interfaces) using Prisms
  • Handling optional fields precisely with Affines

Duration: ~40 minutes | Tutorials: 4 | Exercises: 30

Where This Fits in the Bigger Picture

The .focus().attributes().at(key) token in One Line, Six Layers is composed from the lenses, prisms, and affines this journey teaches. Each tutorial here opens with a Pain → Promise header showing the imperative-Java horror story (copy-constructor cascades, instanceof plus mutate-and-rebuild) the optic replaces.

Journey Overview

This journey teaches the fundamental optics: Lens, Prism, and Affine. By the end, you'll never write verbose immutable update code again.

Lens (product types) → Lens Composition → Prism (sum types) → Affine (optional)

The Optics Hierarchy (Preview)

         Lens (single required field)
              ↓
         Prism (one variant of sum type)
              ↓
         Affine (zero or one element)
              ↓
         Traversal (zero or more elements)

When you compose a Lens with a Prism, you get an Affine. This journey builds that intuition.


Tutorial 01: Lens Basics (~10 minutes)

File: Tutorial01_LensBasics.java | Exercises: 7

Learn immutable field access and modification with Lenses, the foundation of the optics library.

What you'll learn:

  • The three core operations: get, set, modify
  • Using @GenerateLenses to auto-generate lenses for records
  • Manual lens creation with Lens.of()
  • Lens composition with andThen

Key insight: A Lens is a first-class getter/setter. You can pass it around, compose it, and reuse it across your codebase.

Before and After:

// Without lenses (verbose, error-prone)
var updated = new User(user.name(), newEmail, user.address());

// With lenses (clear, composable)
var updated = UserLenses.email().set(newEmail, user);

Real-world application: User profile updates, configuration management, any nested record manipulation.

Links to documentation: Lenses Guide

Hands On Practice


Tutorial 02: Lens Composition (~10 minutes)

File: Tutorial02_LensComposition.java | Exercises: 7

Learn to access deeply nested structures by composing simple lenses into powerful paths.

What you'll learn:

  • Composing lenses with andThen to create deep paths
  • Updating nested fields in a single expression
  • Creating reusable composed lenses
  • The associative property: (a.andThen(b)).andThen(c) == a.andThen(b.andThen(c))

Key insight: Composition is the superpower of optics. Combine small, reusable pieces into complex transformations.

Before and After:

// Without lenses (nightmare)
var newUser = new User(
    user.name(),
    user.email(),
    new Address(
        new Street("New St", user.address().street().number()),
        user.address().city()
    )
);

// With lenses (one line)
var newUser = userToStreetName.set("New St", user);

Real-world application: Updating deeply nested JSON, modifying complex domain models, configuration tree manipulation.

Links to documentation: Composing Optics

Hands On Practice


Tutorial 03: Prism Basics (~10 minutes)

File: Tutorial03_PrismBasics.java | Exercises: 9

Learn to work with sum types (sealed interfaces) safely using Prisms.

What you'll learn:

  • The three core operations: getOptional, build, modify
  • Pattern matching on sealed interfaces
  • Using @GeneratePrisms for automatic generation
  • Using matches() for type checking and doesNotMatch() for exclusion filtering
  • The nearly prism for predicate-based matching
  • Prism composition

Key insight: Prisms are like type-safe instanceof checks with built-in modification capability.

Example scenario: An OrderStatus can be Pending, Processing, or Shipped. A Prism lets you safely operate on just the Shipped variant.

// Safely extract tracking number only if Shipped
Optional<String> tracking = shippedPrism
    .andThen(trackingLens)
    .getOptional(orderStatus);

Real-world application: State machine handling, discriminated unions, API response variants, event processing.

Links to documentation: Prisms Guide | Advanced Prism Patterns

Hands On Practice


Tutorial 04: Affine Basics (~10 minutes)

File: Tutorial04_AffineBasics.java | Exercises: 7

Learn to work with optional fields and nullable properties using Affines.

What you'll learn:

  • The core operations: getOptional, set, modify
  • Using Affines.some() for Optional<T> fields
  • Why Lens.andThen(Prism) produces an Affine, not a Traversal
  • Using matches() and getOrElse() convenience methods
  • Composing Affines for deep optional access
  • When to use Affine vs Lens vs Prism vs Traversal

Key insight: An Affine is more precise than a Traversal when you know there's at most one element. It's what you get when you compose a guaranteed path (Lens) with an uncertain one (Prism).

Decision guide:

OpticFocus CountUse Case
LensExactly 1Required field
Prism0 or 1 (variant)Sum type case
Affine0 or 1 (optional)Optional field
Traversal0 to manyCollection

Real-world application: User profiles with optional contact info, configuration with optional sections, nullable legacy fields.

Links to documentation: Affines Guide

Hands On Practice


Running the Tutorials

./gradlew :hkj-examples:test --tests "*Tutorial01_LensBasics*"
./gradlew :hkj-examples:test --tests "*Tutorial02_LensComposition*"
./gradlew :hkj-examples:test --tests "*Tutorial03_PrismBasics*"
./gradlew :hkj-examples:test --tests "*Tutorial04_AffineBasics*"

Common Pitfalls

1. Forgetting andThen for Composition

Problem: Trying to access nested fields without composing lenses.

Solution: Chain lenses with andThen:

var userToStreetName = UserLenses.address()
    .andThen(AddressLenses.street())
    .andThen(StreetLenses.name());

2. Using Prism.get Instead of getOptional

Problem: Expecting get() on a Prism when the variant doesn't match.

Solution: Prisms return Optional. Always use getOptional():

Optional<Shipped> shipped = shippedPrism.getOptional(orderStatus);

3. Expecting Traversal When You Get Affine

Problem: Thinking Lens + Prism = Traversal.

Solution: Lens + Prism = Affine (zero-or-one, not zero-or-many). Use asTraversal() if needed.


What's Next?

After completing this journey:

  1. Continue to Traversals & Practice: Learn bulk operations on collections
  2. Jump to Focus DSL: Use the ergonomic path-based API
  3. Explore Real Examples: See Auditing Complex Data

Previous: Scope & Resource Next: Optics: Traversals & Practice