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.

supermarket shelves

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 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 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.