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.
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.
Imagine we are modeling data for a supermarket. Products with
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 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
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 classes 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
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
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
forEach. Then once we reach out the shelf, the logic is the same as above.
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