Scrap the Boilerplate in Scala with Lenses

Case classes provide a convenient way of working with immutable objects in scala. Manipulating fields within them can be tedious, however. In this post, we will look at how lenses can be used to abstract over this complexity while preserving the benefits of immutability.

Consider the following example:

case class Address(line1: String, line2: String, postcode: String)
val a = Address("221B", "Baker St", "NW1 6XE")

To update the first line of a particular address, we can simply make a copy of it, overriding the relevant value:

println(a.copy(line1 = "221A"))
// Address(221A,Baker St,NW1 6XE)

To verify that the original value a has not changed as a result of this operation:

println(a)
// Address(221B,Baker St,NW1 6XE)

For shallow updates, using this technique works well. However, suppose the address field is part of some more complex structure; perhaps we have accounts that have an owner, each of whom has an address:

case class Account(ID: Int, owner: Person)
case class Person(name: String, address: Address)

val p = Person("Sherlock Holmes", a)
val acc1 = Account(1, p)

Updating the address for account acc1 then becomes:

val acc2 = acc1.copy(
  owner = acc1.owner.copy(
    address = acc1.owner.address.copy(
      line1 = "221A")))

which we can verify works as expected:

println(acc2) 
// Account(1,Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE)))

Nesting calls to copy in this fashion quickly leads to code that is verbose and difficult to maintain. Fortunately, there is a solution to this problem in the world of functional programming: lenses. Here, we will use monocle‘s GenLens macro to rewrite our example.

import $ivy.`com.github.julien-truffaut::monocle-core:1.4.0`, monocle.Lens
import $ivy.`com.github.julien-truffaut::monocle-macro:1.4.0`, monocle.macros.GenLens

val owner: Lens[Account, Person]   = GenLens[Account](_.owner)
val address: Lens[Person, Address] = GenLens[Person](_.address)
val line1: Lens[Address, String]   = GenLens[Address](_.line1)

The code above defines three lenses, which we can use as ‘getters’ and ‘setters’ for the fields they relate to:

println(owner.get(acc1))
// Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE))

println(line1.set("221A")(a))
// Address(221A,Baker St,NW1 6XE)

At face value, this provides little benefit over the previous approach, since we still need a reference to an address in order to set the first line of it. However, the real power of lenses is that they compose. For example, we can construct a lens that allows us to set the first line of an account owner’s address directly from a reference to the account itself:

val lens: Lens[Account, String] = owner composeLens address composeLens line1

With this in place, we can now write

println(lens.get(acc1))
// 221B

println(lens.set("221A")(acc1))
// Account(1,Person(Sherlock Holmes,Address(221A,Baker St,NW1 6XE)))

…and we are done.

To dig a little deeper into the subject, suppose now that the domain model changes and account owners are no longer required to provide their address:

case class Person(name: String, maybeAddress: Option[Address])

Here, we can no longer use lenses, since we can’t always be guaranteed to get a value back. Instead, we can use monocle’s Optional as a way of querying and modifying an owner’s address. We supply two functions as constructor arguments: these will be used as the ‘getter’ and ‘setter’ respectively:

import $ivy.`com.github.julien-truffaut::monocle-core:1.4.0`, monocle.Optional

val address = Optional[Person, Address](_.maybeAddress)(a => _.copy(maybeAddress = Some(a)))

We can check that this provides access to the address field for an account owner as follows:

val p1  = Person("John Watson", None)
val p2  = Person("Sherlock Holmes", Some(a))

println(address.getOption(p1))
// None

println(address.getOption(p2))
// Some(Address(221B,Baker St,NW1 6XE))

println(address.set(a)(p1))
// Person(John Watson,Some(Address(221B,Baker St,NW1 6XE)))

We can compose Lenses and Optionals using composeOptional. Note that the result of this is always an Optional:

val optional: Optional[Account, Address] = owner composeOptional address

As before, this enables us to manipulate the nested addresses in our updated account type directly:

val acc2 = Account(2, p1)
println(optional.getOption(acc2))
// None

println(optional.set(a)(acc2))
// Account(2,Person(John Watson,Some(Address(221B,Baker St,NW1 6XE))))

The examples presented here only scratch the surface of what lenses (and the wider topic of optics) are capable of. If you are interested in learning more, I would recommend checking out the Monocle docs and experimenting further. The code for all the examples used in this post is available as a GitHub Gist.