Focus on your data structures with Scala lenses

With new programming techniques come new problems and new patterns to solve them.

In functional programming, immutability is a must. As a consequence, whenever it is needed to modify the content of a data structure, a new instance with updated values is created. Depending on how complex the data structure is, creating a copy may be a verbose task.

To simplify the process, a set of functions, generically called Optics, have been designed to access/modify parts of a whole in an easy way. Those functions must obey some laws that make their behaviour predictable and intuitive (for instance, if we modify a value and then read it back, we should obtain the modified value).

This post presents some examples on how to use an Optics library in Scala called Monocle.

Monocle examples

To illustrate the use of Monocle, let’s start by creating a simple domain model:

import monocle.macros.Lenses
sealed trait RoomTariff
case class NonRefundable(fee: BigDecimal) extends RoomTariff
case class Flexible(fee: BigDecimal) extends RoomTariff


@Lenses("_") case class Hotel(name: String, address: String, rating: Int, rooms: List[Room], facilities: Map[String, List[String]])
@Lenses("_") case class Room(name: String, boardType: Option[String], price: Price, roomTariff: RoomTariff)
@Lenses("_") case class Price(amount: BigDecimal, currency: String)

The annotation @Lenses generates automatically a lens for each attribute of the case class.

Let’s create an imaginary hotel:

val rooms = List(
    Room("Double", Some("Half Board"), Price(10, "USD"), NonRefundable(1)),
    Room("Twin", None, Price(20, "USD"), Flexible(0)) ,
    Room("Executive", None, Price(200, "USD"), Flexible(0))
  )
  val facilities = Map("business" -> List("conference room"))
  val hotel = Hotel("Hotel Paradise", "100 High Street", 5, rooms, facilities)

And now the fun part.

Room changes based on the room position in the List

test("double price of even rooms") {

    val updatedHotel = (_rooms composeTraversal filterIndex{i: Int => i/2*2 == i} composeLens _price composeLens _amount modify(_ * 2)) (hotel)

    assert(updatedHotel.rooms(0).price.amount == hotel.rooms(0).price.amount * 2)
    assert(updatedHotel.rooms(1).price.amount == hotel.rooms(1).price.amount)
    assert(updatedHotel.rooms(2).price.amount == hotel.rooms(2).price.amount * 2)
  }

  test("set price of 2nd room") {

    val newValue = 12
    val roomToUpdate = 1

    assert(hotel.rooms(roomToUpdate).price.amount != newValue)

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel)
    val updatedRoomList = (index[List[Room], Int, Room](roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel.rooms)

    assert(updatedHotel.rooms(roomToUpdate).price.amount == newValue)
    assert(updatedRoomList(roomToUpdate).price.amount == newValue)
  }

Modifying a non-existing room

test("no changes are made when attempting to modify a non-existing room") {

    val newValue = 12
    val roomToUpdate = 3

    assert(hotel.rooms.length == 3)

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount set newValue)(hotel)

    assert(hotel == updatedHotel)
  }

  test("hotel 'disappears' when attempting to modify a non-existing room") {

    val newValue = 12
    val roomToUpdate = 3

    assert(hotel.rooms.length == 3)

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _price composeLens _amount setOption newValue)(hotel)

    assert(updatedHotel.isEmpty)
  }

Changing an optional value

test("set a value inside an Option") {

    val newValue = "New Board Type"
    val roomToUpdate = 0

    assert(!hotel.rooms(roomToUpdate).boardType.contains(newValue))

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional set newValue)(hotel)

    assert(updatedHotel.rooms(roomToUpdate).boardType.contains(newValue))
  }

  test("no changes are made when attempting to modify an empty Option") {

    val newValue = "New Board Type"
    val roomToUpdate = 1

    assert(hotel.rooms(roomToUpdate).boardType.isEmpty)

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional set newValue)(hotel)

    assert(updatedHotel.rooms(roomToUpdate).boardType.isEmpty)
  }

  test("hotel 'disappears' when attempting to modify an empty Option") {

    val newValue = "New Board Type"
    val roomToUpdate = 1

    assert(hotel.rooms(roomToUpdate).boardType.isEmpty)

    val updatedHotel = (_rooms composeOptional index(roomToUpdate) composeLens _boardType composeOptional some.asOptional setOption newValue)(hotel)

    assert(updatedHotel.isEmpty)
  }

Changes with an applicative function

test("divide prices by 10"){

    assert(hotel.rooms(0).price.amount == 10)
    assert(hotel.rooms(1).price.amount == 20)

    val updatedHotel = (_rooms composeTraversal each composeLens _price composeLens _amount modify(_ / 10))(hotel)

    assert(updatedHotel.rooms(0).price.amount == 1)
    assert(updatedHotel.rooms(1).price.amount == 2)
  }

  test("divide prices by 0"){

    assert(hotel.rooms(0).price.amount == 10)
    assert(hotel.rooms(1).price.amount == 20)

    val updatedHotel = (_rooms composeTraversal each composeLens _price composeLens _amount).modifyF[Option](y => Try{y / 0}.toOption)(hotel)

    assert(updatedHotel.isEmpty)
  }

Modifying number of rooms

test("append a room"){

    assert(hotel.rooms.length == 3)

    val newRoom = Room("Triple", None, Price(1, "USD"), Flexible(0))

    val updatedHotel = (_rooms set _snoc(hotel.rooms, newRoom))(hotel)

    assert(updatedHotel.rooms.length == 4)
    assert(updatedHotel.rooms(3) == newRoom)
  }

  test("prepend a room"){

    assert(hotel.rooms.length == 3)

    val newRoom = Room("Triple", None, Price(1, "USD"), Flexible(0))

    val updatedHotel = (_rooms set _cons(newRoom, hotel.rooms))(hotel)

    assert(updatedHotel.rooms.length == 4)
    assert(updatedHotel.rooms(0) == newRoom)
  }

Using prisms to modify the room tariff

test("set prices of Flexible rooms"){

    val prism = Prism.partial[RoomTariff, BigDecimal]{case Flexible(x) => x}(Flexible)

    val newValue = 100

    assert(hotel.rooms(0).roomTariff == NonRefundable(1))
    assert(hotel.rooms(1).roomTariff == Flexible(0))
    assert(hotel.rooms(2).roomTariff == Flexible(0))

    val updatedHotel = (_rooms composeTraversal each composeLens _roomTariff composePrism prism set newValue)(hotel)

    assert(hotel.rooms(0).roomTariff == updatedHotel.rooms(0).roomTariff)
    assert(updatedHotel.rooms(1).roomTariff == Flexible(newValue))
    assert(updatedHotel.rooms(2).roomTariff == Flexible(newValue))
  }

Manipulating a Map

test("modifying business facilities") {

    val updatedHotel = (_facilities composeLens at("business") set Some(List("")))(hotel)

    assert(updatedHotel.facilities("business") == List(""))
  }

  test("removing business facilities") {

    val updatedHotel = (_facilities composeLens at("business") set None)(hotel)
    val updatedFacilities = remove("business")(hotel.facilities)

    assert(updatedHotel.facilities.get("business").isEmpty)
    assert(updatedFacilities.get("business").isEmpty)
  }

  test("adding entertainment facilities") {

    val updatedHotel = (_facilities composeLens at("entertainment") set  Some(List("satellite tv", "internet")))(hotel)

    assert(updatedHotel.facilities("entertainment") == List("satellite tv", "internet"))
  }

Folding over the room list

test("folding over room prices to add them up") {

    assert(hotel.rooms(0).price.amount == 10)
    assert(hotel.rooms(1).price.amount == 20)
    assert(hotel.rooms(2).price.amount == 200)

    assert((_rooms composeFold Fold.fromFoldable[List, Room] foldMap(_.price.amount))(hotel) == 230)
  }

Modifying rooms that meet specific criteria

val unsafePrism = UnsafeSelect.unsafeSelect[Room](_.name == "Double")
  test("double price of Double rooms using unsafe operation") {

    val updatedHotel = (_rooms composeTraversal each composePrism unsafePrism composeLens _price composeLens _amount modify (_ * 2)) (hotel)

    assert(hotel.rooms.filter(_.name == "Double").map(_.price.amount*2) == updatedHotel.rooms.filter(_.name == "Double").map(_.price.amount))
  }

This last example makes use of an unsafe prism (it is unsafe because does not comply with all Prism laws). Let’s verify this statement by testing the laws:

val roomGen: Gen[Room] = for {
    name <- Gen.oneOf("Double", "Twin", "Executive")
    board <- Gen.option(Gen.alphaStr)
    price <- for{
      price <- Gen.posNum[Double]
      currency <- Gen.oneOf("USD", "GBP", "EUR")
    } yield Price(price, currency)
    tariff <- Gen.oneOf(Gen.posNum[Double].map(NonRefundable(_)), Gen.posNum[Double].map(Flexible(_)))
  } yield Room(name, board, price, tariff)

  implicit val roomArb: Arbitrary[Room] = Arbitrary(roomGen)

  implicit val arbAA: Arbitrary[Room => Room] = Arbitrary{
    for{
      room <- roomGen
    } yield (_: Room) => room
  }

  checkAll("unsafe prism", PrismTests(unsafePrism))

When running the above test, the following tests fail:

  • Prism.compose modify
  • Prism.round trip other way

So, what is wrong? Let’s check the law “round trip other way”. Here’s its definition on PrismLaws:

def roundTripOtherWay(a: A): IsEq[Option[A]] =
    prism.getOption(prism.reverseGet(a)) <==> Some(a)

And this is how the law is broken:

val a = Room(Twin,None,Price(1.0,USD),Flexible(1.0))
val b = unsafePrism.reverseGet(a) = Room(Twin,None,Price(1.0,USD),Flexible(1.0))
val c = unsafePrism.getOption(b) = None

None != Some(a)

So, our unsafePrism is unsafe when used to make changes on the attribute included in the predicate to create the prism.

All the above examples can be found on this repo.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.