or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

advanced-utilities.mdcoproduct-unions.mdgeneric-derivation.mdhlist-collections.mdindex.mdoptics-lenses.mdpoly-typelevel.mdrecords-fields.md
tile.json

optics-lenses.mddocs/

Optics - Lenses and Prisms

Optics in Shapeless provide functional programming patterns for composable data access and transformation. They enable type-safe navigation and modification of nested data structures with automatic derivation, offering an elegant alternative to manual field access and updates.

Capabilities

Lens Types

Lenses provide bidirectional access to parts of data structures, enabling both getting and setting values in a composable way.

// Core lens trait for accessing field A in structure S
trait Lens[S, A] extends LPLens[S, A] {
  def get(s: S): A
  def set(s: S)(a: A): S
  
  // Transform the focused value using function f
  def modify(s: S)(f: A => A): S = set(s)(f(get(s)))
  
  // Compose with another lens
  def compose[T](g: Lens[T, S]): Lens[T, A]
  
  // Compose with a prism
  def compose[T](g: Prism[T, S]): Prism[T, A]
  
  // Navigate to nth field
  def >>(n: Nat)(implicit mkLens: MkNthFieldLens[A, n.N]): Lens[S, mkLens.Elem]
  
  // Navigate to named field
  def >>(k: Witness)(implicit mkLens: MkFieldLens[A, k.T]): Lens[S, mkLens.Elem]
  
  // Pattern match extraction
  def unapply(s: S): Option[A] = Some(get(s))
}

Prism Types

Prisms provide partial access to sum types and optional values, handling cases where the focused value may not be present.

// Core prism trait for partial access to A within S
trait Prism[S, A] extends LPPrism[S, A] {
  def get(s: S): Option[A]
  def set(s: S)(a: A): S
  
  // Transform the focused value if present
  def modify(s: S)(f: A => A): S = get(s).map(f).map(a => set(s)(a)).getOrElse(s)
  
  // Compose with lens
  def compose[T](g: Lens[T, S]): Prism[T, A]
  
  // Compose with another prism
  def compose[T](g: Prism[T, S]): Prism[T, A]
  
  // Dynamic field selection
  def selectDynamic(k: String)(implicit mkPrism: MkSelectDynamicOptic[Prism[S, A], A, Symbol @@ k.type, Nothing]): mkPrism.Out
  
  // Constructor pattern matching
  def apply[B](implicit mkPrism: MkCtorPrism[A, B]): Prism[S, B]
}

// Optional type for lens-prism composition
type Optional[S, A] = Prism[S, A]

Path Construction

Dynamic path construction for navigating nested structures.

object Path {
  // Start path construction
  def apply[S]: PathBuilder[S, S]
  
  // Path builder for composing optics
  trait PathBuilder[S, A] {
    def selectField[B](field: Witness.Aux[Symbol]): PathBuilder[S, B]
    def selectIndex[B](index: Nat): PathBuilder[S, B]  
    def selectType[B]: PathBuilder[S, B]
  }
}

// OpticDefns provides construction utilities
object OpticDefns {
  // Create lens to field
  def lens[S]: MkFieldLens[S]
  
  // Create prism for coproduct injection
  def prism[S]: MkCoproductPrism[S]
  
  // Generic optic construction
  def optic[S]: GenericOptics[S]
}

// Aliases for convenience
val optic = OpticDefns.optic
val lens = OpticDefns.lens  
val prism = OpticDefns.prism

Automatic Optic Derivation

Type classes for automatically deriving lenses and prisms from generic representations.

// Generate lens for accessing field K in type S
trait MkFieldLens[S, K] {
  type A
  def apply(): Lens[S, A]
}

// Generate lens for nth element of HList
trait MkHListNthLens[L <: HList, N <: Nat] {
  type A
  def apply(): Lens[L, A]
}

// Generate prism for coproduct injection
trait MkCoproductPrism[C <: Coproduct, T] {
  def apply(): Prism[C, T]
}

// Generate lens for generic product
trait MkGenericLens[S, T] {
  def apply(): Lens[S, T]
}

// Usage:
case class Address(street: String, city: String, zip: String)
val streetLens = MkFieldLens[Address, 'street].apply()
val address = Address("123 Main St", "Anytown", "12345")
val street = streetLens.get(address)  // "123 Main St"

Optic Operations

Higher-level operations and combinators for working with optics.

// Traverse operation for collections
trait MkTraversal[S, T] {
  def apply(): Traversal[S, T]
}

// Iso for bidirectional transformations
case class Iso[S, A](get: S => A, set: A => S) {
  def reverse: Iso[A, S] = Iso(set, get)
  def >>[B](other: Iso[A, B]): Iso[S, B]
}

// Fold for reading multiple values
trait Fold[S, A] {
  def foldMap[M: Monoid](s: S)(f: A => M): M
  def toList(s: S): List[A]
}

// Setter for write-only access
trait Setter[S, A] {
  def set(s: S, a: A): S
  def modify(s: S)(f: A => A): S
}

Usage Examples

Basic Lens Operations

import shapeless._, lens._

case class Person(name: String, age: Int, address: Address)
case class Address(street: String, city: String, zip: String)

val person = Person("Alice", 30, Address("123 Main St", "Boston", "02101"))

// Create lenses
val nameLens = lens[Person] >> 'name
val ageLens = lens[Person] >> 'age  
val streetLens = lens[Person] >> 'address >> 'street

// Get values
val name = nameLens.get(person)        // "Alice"
val age = ageLens.get(person)          // 30
val street = streetLens.get(person)    // "123 Main St"

// Set values
val renamed = nameLens.set(person, "Alicia")
val older = ageLens.modify(person)(_ + 1)
val moved = streetLens.set(person, "456 Oak Ave")

println(renamed)  // Person("Alicia", 30, Address("123 Main St", "Boston", "02101"))
println(older)    // Person("Alice", 31, Address("123 Main St", "Boston", "02101"))

Prism Operations with Sum Types

sealed trait Shape  
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape

val shapes: List[Shape] = List(
  Circle(5.0),
  Rectangle(10.0, 20.0),
  Circle(3.0),
  Triangle(8.0, 12.0)
)

// Create prisms for each case
val circlePrism = prism[Shape] >> 'Circle
val rectanglePrism = prism[Shape] >> 'Rectangle

// Extract specific shapes
val circles = shapes.flatMap(circlePrism.get)
// List(Circle(5.0), Circle(3.0))

val rectangles = shapes.flatMap(rectanglePrism.get)  
// List(Rectangle(10.0, 20.0))

// Modify specific shapes
val scaledShapes = shapes.map { shape =>
  circlePrism.modify(shape)(circle => circle.copy(radius = circle.radius * 2))
}

Nested Data Access

case class Company(name: String, employees: List[Employee])
case class Employee(name: String, role: Role, contact: Contact)
case class Role(title: String, department: String, level: Int)
case class Contact(email: String, phone: String)

val company = Company("TechCorp", List(
  Employee("Bob", Role("Developer", "Engineering", 3), Contact("bob@tech.com", "555-1001")),
  Employee("Carol", Role("Manager", "Engineering", 5), Contact("carol@tech.com", "555-1002"))
))

// Deep lens composition
val firstEmployeeEmailLens = 
  lens[Company] >> 'employees >> 
  lens[List[Employee]] >> at(0) >>
  lens[Employee] >> 'contact >> 
  lens[Contact] >> 'email

// Access deeply nested field
val firstEmail = firstEmployeeEmailLens.get(company)  // "bob@tech.com"

// Update deeply nested field
val updatedCompany = firstEmployeeEmailLens.set(company, "robert@tech.com")

Optional and Partial Access

// Working with optional values
case class User(name: String, profile: Option[Profile])
case class Profile(bio: String, avatar: Option[String])

val user = User("Dave", Some(Profile("Software engineer", None)))

// Lens to optional field
val profileLens = lens[User] >> 'profile
val bioLens = profileLens >>? lens[Profile] >> 'bio

// Get optional bio
val bio = bioLens.get(user)  // Some("Software engineer")

// Update bio if profile exists  
val updatedUser = bioLens.modify(user)(_ + " at TechCorp")

// Handle missing profile
val userWithoutProfile = User("Eve", None)
val noBio = bioLens.get(userWithoutProfile)  // None
val stillNone = bioLens.modify(userWithoutProfile)(_ + " update")  // No change

Collection Traversal

// Traverse collections with optics
case class Team(name: String, members: List[Person])

val team = Team("Alpha", List(
  Person("Alice", 30, Address("123 Main", "Boston", "02101")),
  Person("Bob", 25, Address("456 Oak", "Boston", "02102"))
))

// Create traversal for all team member ages
val memberAgesTraversal = lens[Team] >> 'members >> each >> 'age

// Get all ages
val ages = memberAgesTraversal.toList(team)  // List(30, 25)

// Increment all ages
val olderTeam = memberAgesTraversal.modify(team)(_ + 1)
// All team members now one year older

// Filter and modify
val filteredUpdate = team.copy(
  members = team.members.map { person =>
    if (person.age > 28) ageLens.modify(person)(_ + 1) else person
  }
)

Dynamic Field Access

// Dynamic optic construction
def updateField[S, A](obj: S, fieldName: String, newValue: A)(implicit 
  lens: MkFieldLens[S, fieldName.type]
): S = {
  val fieldLens = lens.apply()
  fieldLens.set(obj, newValue)
}

// Usage with compile-time field name verification
val person = Person("Frank", 40, Address("789 Pine", "Seattle", "98101"))
val updated = updateField(person, "age", 41)
// Compile error if field doesn't exist

Lens Laws and Validation

// Lens laws - get/set coherence
def validateLens[S, A](lens: Lens[S, A], obj: S): Boolean = {
  val original = lens.get(obj)
  val restored = lens.set(obj, original)
  
  // Law 1: get(set(obj, value)) == value
  val setValue = 42.asInstanceOf[A]  
  val law1 = lens.get(lens.set(obj, setValue)) == setValue
  
  // Law 2: set(obj, get(obj)) == obj  
  val law2 = lens.set(obj, lens.get(obj)) == obj
  
  // Law 3: set(set(obj, a), b) == set(obj, b)
  val law3 = {
    val a = original
    val b = setValue
    lens.set(lens.set(obj, a), b) == lens.set(obj, b)
  }
  
  law1 && law2 && law3
}

// Validate lens correctness
val isValid = validateLens(nameLens, person)  // true