Cookbook

Runnable patterns for the questions that come up most often. Every fence is compiled by mdoc against the current library version.

import eo.optics.{Iso, Lens, Optic, Optional, Prism, Setter, Traversal}
import eo.optics.Optic.*
import eo.data.Forgetful.given    // Accessor[Forgetful] — powers .get on Iso / Getter

Edit a deeply-nested field

Using hand-written Lenses (the generics module provides the lens[S](_.field) macro that generates this boilerplate):

case class Zip(code: Int, extension: String)
case class Address(street: String, zip: Zip)
case class Company(name: String, address: Address)

val extL =
  Lens[Company, Address](_.address, (c, a) => c.copy(address = a))
    .andThen(Lens[Address, Zip](_.zip, (a, z) => a.copy(zip = z)))
    .andThen(Lens[Zip, String](_.extension, (z, e) => z.copy(extension = e)))
val initech = Company("Initech", Address("Main St", Zip(12345, "6789")))
// initech: Company = Company(
//   name = "Initech",
//   address = Address(
//     street = "Main St",
//     zip = Zip(code = 12345, extension = "6789")
//   )
// )
extL.get(initech)
// res0: String = "6789"
extL.replace("0000")(initech)
// res1: Company = Company(
//   name = "Initech",
//   address = Address(
//     street = "Main St",
//     zip = Zip(code = 12345, extension = "0000")
//   )
// )

Focus an Option field (Optional-shaped)

import eo.data.Affine

case class Setting(name: String, timeout: Option[Int])

val timeoutL = Optional[Setting, Setting, Int, Int, Affine](
  getOrModify = s => s.timeout.toRight(s),
  reverseGet  = { case (s, t) => s.copy(timeout = Some(t)) },
)
timeoutL.modify(_ * 2)(Setting("a", Some(10)))
// res2: Setting = Setting(name = "a", timeout = Some(20))
timeoutL.modify(_ * 2)(Setting("a", None))
// res3: Setting = Setting(name = "a", timeout = None)

Compose a Lens with an Optional

Morph the Lens into the Optional's carrier so they share F:

case class AppRoot(setting: Setting)

val appTimeoutL =
  Lens[AppRoot, Setting](_.setting, (a, s) => a.copy(setting = s))
    .morph[Affine]
    .andThen(timeoutL)
appTimeoutL.modify(_ * 2)(AppRoot(Setting("a", Some(5))))
// res4: AppRoot = AppRoot(Setting(name = "a", timeout = Some(10)))
appTimeoutL.modify(_ * 2)(AppRoot(Setting("a", None)))
// res5: AppRoot = AppRoot(Setting(name = "a", timeout = None))

Modify every element of a list

Traversal.each is the linear-time fast path:

import cats.instances.list.given

val eachInt = Traversal.each[List, Int, Int]
eachInt.modify(_ + 1)(List(1, 2, 3))
// res6: List[Int] = List(2, 3, 4)
eachInt.foldMap(identity[Int])(List(1, 2, 3))     // sum
// res7: Int = 6
eachInt.foldMap((_: Int) => 1)(List(1, 2, 3))     // count
// res8: Int = 3

Modify every element and continue through a field

Reach for Traversal.powerEach when the chain continues after the traversal (see Optics → Traversal for the cost tradeoff):

import eo.data.PowerSeries

case class Dial(isMobile: Boolean, number: String)
case class Subscriber(phones: List[Dial])

val everyMobile =
  Lens[Subscriber, List[Dial]](_.phones, (o, ps) => o.copy(phones = ps))
    .morph[PowerSeries]
    .andThen[Dial, Dial](Traversal.powerEach[List, Dial])
    .andThen(
      Lens[Dial, Boolean](_.isMobile, (p, m) => p.copy(isMobile = m))
        .morph[PowerSeries]
    )
everyMobile.modify(!_)(Subscriber(List(
  Dial(isMobile = false, "555-0001"),
  Dial(isMobile = true,  "555-0002"),
)))
// res9: Subscriber = Subscriber(
//   List(
//     Dial(isMobile = true, number = "555-0001"),
//     Dial(isMobile = false, number = "555-0002")
//   )
// )

Branch into a sum type

enum Input:
  case Click(x: Int, y: Int)
  case Scroll(delta: Int)

val clickP = Prism[Input, Input.Click](
  {
    case c: Input.Click => Right(c)
    case other          => Left(other)
  },
  identity,
)
clickP.modify(c => Input.Click(c.x + 1, c.y))(Input.Click(10, 20))
// res10: Input = Click(x = 11, y = 20)
clickP.modify(c => Input.Click(c.x + 1, c.y))(Input.Scroll(5))
// res11: Input = Scroll(5)

Edit JSON without decoding

import eo.circe.codecPrism
import io.circe.Codec
import io.circe.syntax.*
import hearth.kindlings.circederivation.KindlingsCodecAsObject

case class UserAddress(street: String, zip: Int)
object UserAddress:
  given Codec.AsObject[UserAddress] = KindlingsCodecAsObject.derive

case class SiteUser(name: String, address: UserAddress)
object SiteUser:
  given Codec.AsObject[SiteUser] = KindlingsCodecAsObject.derive

val userStreet = codecPrism[SiteUser].address.street
val userJson = SiteUser("Alice", UserAddress("Main St", 12345)).asJson
// userJson: Json = JObject(
//   object[name -> "Alice",address -> {
//   "street" : "Main St",
//   "zip" : 12345
// }]
// )
userStreet.modify(_.toUpperCase)(userJson).noSpacesSortKeys
// res12: String = "{\"address\":{\"street\":\"MAIN ST\",\"zip\":12345},\"name\":\"Alice\"}"

Apply a function that needs an effect

Both .modifyF and .modifyA lift an A => G[B] through an optic. Use .modifyF when the carrier admits just Functor[G], .modifyA when you need the full Applicative[G]:

case class Shopper(name: String, age: Int)
val shopperAgeL =
  Lens[Shopper, Int](_.age, (s, a) => s.copy(age = a))
import cats.syntax.functor.*
import cats.instances.option.*

shopperAgeL.modifyF[Option](age => if age >= 0 then Some(age + 1) else None)(
  Shopper("Alice", 30)
)
// res13: Option[Shopper] = Some(Shopper(name = "Alice", age = 31))

shopperAgeL.modifyF[Option](age => if age >= 0 then Some(age + 1) else None)(
  Shopper("Alice", -1)
)
// res14: Option[Shopper] = None

Iso between equivalent shapes

case class UserTuple(name: String, age: Int)
case class UserRecord(name: String, age: Int)

val userIso =
  Iso[UserTuple, UserTuple, UserRecord, UserRecord](
    ut => UserRecord(ut.name, ut.age),
    ur => UserTuple(ur.name, ur.age),
  )
userIso.get(UserTuple("Alice", 30))
// res15: UserRecord = UserRecord(name = "Alice", age = 30)
userIso.reverseGet(UserRecord("Bob", 25))
// res16: UserTuple = UserTuple(name = "Bob", age = 25)

Derive a read-only view

import eo.optics.Getter

val nameInitial = Getter[Shopper, Char](_.name.head)
nameInitial.get(Shopper("Alice", 30))
// res17: Char = 'A'

Further reading