Introduction to optics in Scala
Throughout this blog I’ve mentioned the benefits of having a strong type system a multiple times. I’ve covered type refinement for value validation, beginner friendly and advanced approach to type-class derivation or type-safe approach to messaging with pass4s.
The possibilities are endless though, so today I want to introduce another topic, that beginners in languages like Scala or Haskell might not be aware of.
In this post I’ll talk about Optics.
Meet Optics
Optics is an umbrella term for accessing and transforming immutable data. We’ll explore the world of optics with the great library Monocle. Before we get started, let’s set the stage and with some problem we can refer to.
The domain
Imagine we are modeling data for a supermarket. Products with id
, name
and price
, stored on shelves grouped in displays and so on. We aim to model the whole complex structure.
Let’s start simple, with a product:
case class Product(id: String, name: String, price: Double)
Those are contained on a shelf:
case class Shelf(id: String, product: Product)
Shelves build up a display:
case class Display(id: String, kind: "Ambient" | "Chilled", shelves: List[Shelf])
Displays build up an alley
case class Alley(id: String, displays: List[Display])
The shop is just a bunch of alleys
case class Shop(alleys: List[Alley])
The problem
The task sounds simple - we want to apply a 10% discount in the entire shop. The entire shop stays the same, only prices change. Do you imagine doing it the old fashion way? sounds like a lot of map
and copy
. Try it yourself as an exercise!
To have some test data let’s say our shop looks like this:
val shop = Shop(
alleys = List(
Alley(
id = "1",
displays = List(
Display("1", "Ambient", List(Shelf("1", water), Shelf("2", milk))),
Display("2", "Chilled", List(Shelf("3", cheese), Shelf("4", ham)))
)
)
)
)
Solve it with Optics
Let’s start simple and take just a subset of that, let’s discount a single shelf:
val shelf = Shelf("1", water)
We’ll use scala-cli for our solution. Let’s start with setting up the dependencies and including syntax from Monocle.
//> using scala "3.3.0"
//> using dep "dev.optics::monocle-core:3.2.0"
//> using dep "dev.optics::monocle-macro:3.2.0"
import monocle.syntax.all._
Thanks to the syntax import, all case class
es are now enriched with optics manipulators. With those, applying discount to a shelf
is as simple as:
shelf
.focus(_.product.price)
.modify(_ * 0.9)
A word of explanation focus
tells the optics how to navigate to the price
. Then we apply the discount by calling modify
.
Now that we know the basics, let’s apply the discount to the entire shop as defined above.
val discounted =
shop
.focus(_.alleys)
.each
.refocus(_.displays)
.each
.refocus(_.shelves)
.each
.refocus(_.product.price)
.modify(_ * 0.9)
Now there’s a bit of repeating focus
and each
. They mean that we focus on a List
type of field, and then instruct the optics to apply the upcoming combinators to each
of items. This resembles the workings of map
or forEach
. Then once we reach out the shelf, the logic is the same as above.
Summary
That’s it for the introduction! There’s a lot more functionality in Monocle and we have just scratched the surface. There’s a plenty of application, personally I find optics to be extremely useful for manipulating data in integration tests when you need to see how larger data structures are changed by entire software components.
Want to learn more? Check out the Monocle documentation https://www.optics.dev/Monocle/
Want to see full code example? It’s in the gist https://gist.github.com/majk-p/dfdcf08bdfc3986c3dcd94cc02fa4f52 - you can always run it with scala-cli
.