1. Introduction
In this tutorial, we’re going to explore how to use some of the most well-known optics to access and modify nested case classes in Scala in a concise and elegant way. We’re going to use Monocle, a well-known Scala optics library.
2. What Are Optics and Why Do We Need Them?
To quote from the Monocle definition:
Optics are a group of purely functional abstractions to manipulate (get, set, modify, …) immutable objects.
Modifying nested case classes in Scala can be really verbose and requires lots of boilerplate code, making the code difficult to understand. We’ll see in the following section an example that shows how verbose the code can be when using pure Scala.
Optics will solve this problem by providing methods to manipulate complex data structures in a concise way.
Let’s get started by adding the Monocle dependencies to our build.sbt file:
val monocleVersion = "2.1.0"
libraryDependencies ++= Seq(
"com.github.julien-truffaut" %% "monocle-core" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-macro" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-law" % monocleVersion % "test"
)
The library is cross-built for Scala 2.12 and 2.13 and published to Maven Central.
3. Available Optics
Monocle provides several optics, which can be used for different purposes. In this article, we’re going to show a possible application for each of the main ones.
We’re going to use a simple domain model to show how optics work. Our model is a nested structure, describing an overly simplified relationship between a User with its Cart, containing a single type of Item:
case class User(name: String, cart: Cart)
case class Cart(id: String, item: Item, quantity: Int)
case class Item(sku: String, price: Double, leftInStock: Int, discount: Discount)
We then have an ADT (Algebraic Data Type) describing possible types of discounts applicable to products:
trait Discount
case class NoDiscount() extends Discount
case class PercentageOff(value: Double) extends Discount
case class FixPriceOff(value: Double) extends Discount
3.1. Lens
We’re starting with Lens, the most famous optic, which provides a way to zoom in on our data structure.
Let’s say that, for instance, we want to update the number of items left in stock when a User purchases a product.
In vanilla Scala, we’d have to access and modify each level of the nested structure in a very verbose way:
def updateStockWithoutLenses(user: User): User = {
user.copy(
cart = user.cart.copy(
item = user.cart.item.copy(leftInStock = user.cart.item.leftInStock - 1)
)
)
}
Let’s see how Lens can help us with this case. Lens provides a pair of functions:
get(s: S): A
set(a: A): S => S
In the above method definitions, S is the so-called Product (User case classes in our example),* and A is an element inside S (the Item name).*
We’re now going to rewrite our previous example with Lens:
def updateStockWithLenses(user: User): User = {
val cart: Lens[User, Cart] = GenLens[User](_.cart)
val item: Lens[Cart, Item] = GenLens[Cart](_.item)
val leftInStock: Lens[Item, Int] = GenLens[Item](_.leftInStock)
(cart composeLens item composeLens leftInStock).modify(_ - 1)(user)
}
Pretty concise, isn’t it? *The composeLens method, as the name suggests, allows us to compose different Lenses together and zoom into each nested level of the data structure.*
3.2. Optional
Similarly to Lens, Optional allows zooming into a data structure. However, the element we’re focusing on might not exist.
Two methods characterize an Optional:
getOption: S => Option[A]
set: A => S => S
As in the previous optic, S* is the Product, and A is an element inside *S.
Now, we’re going to implement a method that returns the discount value whenever it is present:
def getDiscountValue(discount: Discount): Option[Double] = {
val maybeDiscountValue = Optional[Discount, Double] {
case pctOff: PercentageOff => Some(pctOff.value)
case fixOff: FixPriceOff => Some(fixOff.value)
case _ => None
} { discountValue => discount =>
discount match {
case pctOff: PercentageOff => pctOff.copy(value = discountValue)
case fixOff: FixPriceOff => fixOff.copy(value = discountValue)
case _ => discount
}
}
maybeDiscountValue.getOption(discount)
}
Let’s see some tests to show the usage of the Optional optic:
it should "return the Fix Off discount value" in {
val value = 3L
assert(getDiscountValue(FixPriceOff(value)) == Some(value))
}
it should "return no discount value" in {
assert(getDiscountValue(NoDiscount()) == None)
}
3.3. Prism
Prism is an optic that allows us to select only part of a data model. It supplies two methods:
getOption: S => Option[A]
reverseGet: A => S
In this case, S is the Sum (an ADT, like Discount in our case) and A a part of the Sum. Note the optionality of the getter, which might not match any part of the Sum.
Let’s write a function that only updates percentage-off discounts:
def updateDiscountedItemsPrice(cart: Cart, newDiscount: Double): Cart = {
val discountLens: Lens[Item, Discount] = GenLens[Item](_.discount)
val onlyPctDiscount = Prism.partial[Discount, Double] {
case PercentageOff(p) => p
}(PercentageOff)
val newItem =
(discountLens composePrism onlyPctDiscount set newDiscount)(cart.item)
cart.copy(item = newItem)
}
Similarly to Lens, the composePrism method is available for functional composition.
Our Prism (onlyPctDiscount) will only update PercentageOff discount types:
it should "update discount percentage values" in {
val originalDiscount = 10L
val newDiscount = 5L
val updatedCart = updateDiscountedItemsPrice(
Cart("abc", Item("item123", 23L, 1, PercentageOff(originalDiscount)), 1),
newDiscount
)
assert(updatedCart.item.discount == PercentageOff(newDiscount))
}
it should "not update discount fix price values" in {
val originalDiscount = 10L
val newDiscount = 5L
val updatedCart = updateDiscountedItemsPrice(
Cart("abc", Item("item123", 23L, 1, FixPriceOff(originalDiscount)), 1),
newDiscount
)
assert(updatedCart.item.discount == FixPriceOff(originalDiscount))
}
3.4. Iso
Iso is another type of optic, useful when trying to represent the same data in different ways.
Let’s say we want to represent prices in Euro and in GBP:
case class PriceEUR(value: Double)
case class PriceGBP(value: Double)
We could write the following Iso for the transformation between currencies:
val tranformCurrency = Iso[PriceEUR, PriceGBP] { eur =>
PriceGBP(eur.value * 0.9)
}{ gbp =>
PriceEUR(gbp.value / 0.9)
}
We can then use it for the conversion:
it should "transform GBP to EUR correctly" in {
val x = tranformCurrency.modify(gbp => gbp.copy(gbp.value + 90L))(PriceEUR(1000L))
assert(x.value == 1100L)
}
4. Conclusion
Whenever we need to traverse and modify a nested data structure or we need to represent the same data in different ways, optics help us to write our code in a more succinct way.
Delegating the boilerplate code to traverse or transform our data structure to optics allows us to concentrate on the more important aspects of our code.
As always, the code is available over on GitHub.