Search code examples
scalacase-classlenses

How to modify this nested case classes with "Seq" fields?


Some nested case classes and the field addresses is a Seq[Address]:

// ... means other fields
case class Street(name: String, ...)
case class Address(street: Street, ...)
case class Company(addresses: Seq[Address], ...)
case class Employee(company: Company, ...)

I have an employee:

val employee = Employee(Company(Seq(
    Address(Street("aaa street")),
    Address(Street("bbb street")),
    Address(Street("bpp street")))))

It has 3 addresses.

And I want to capitalize the streets start with "b" only. My code is mess like following:

val modified = employee.copy(company = employee.company.copy(addresses = 
    employee.company.addresses.map { address =>
        address.copy(street = address.street.copy(name = {
          if (address.street.name.startsWith("b")) {
            address.street.name.capitalize
          } else {
            address.street.name
          }
        }))
      }))

The modified employee is then:

Employee(Company(List(
    Address(Street(aaa street)), 
    Address(Street(Bbb street)), 
    Address(Street(Bpp street)))))

I'm looking for a way to improve it, and can't find one. Even tried Monocle, but can't apply it to this problem.

Is there any way to make it better?


PS: there are two key requirements:

  1. use only immutable data
  2. don't lose other existing fields

Solution

  • As Peter Neyens points out, Shapeless's SYB works really nicely here, but it will modify all Street values in the tree, which may not always be what you want. If you need more control over the path, Monocle can help:

    import monocle.Traversal
    import monocle.function.all._, monocle.macros._, monocle.std.list._
    
    val employeeStreetNameLens: Traversal[Employee, String] =
      GenLens[Employee](_.company).composeTraversal(
        GenLens[Company](_.addresses)
          .composeTraversal(each)
          .composeLens(GenLens[Address](_.street))
          .composeLens(GenLens[Street](_.name))
      )
    
      val capitalizer = employeeStreeNameLens.modify {
        case s if s.startsWith("b") => s.capitalize
        case s => s
      }
    

    As Julien Truffaut points out in an edit, you can make this even more concise (but less general) by creating a lens all the way to the first character of the street name:

    import monocle.std.string._
    
    val employeeStreetNameFirstLens: Traversal[Employee, Char] =
      GenLens[Employee](_.company.addresses)
        .composeTraversal(each)
        .composeLens(GenLens[Address](_.street.name))
        .composeOptional(headOption)
    
    val capitalizer = employeeStreetNameFirstLens.modify {
      case 'b' => 'B'
      case s   => s
    }
    

    There are symbolic operators that would make the definitions above a little more concise, but I prefer the non-symbolic versions.

    And then (with the result reformatted for clarity):

    scala> capitalizer(employee)
    res3: Employee = Employee(
      Company(
        List(
          Address(Street(aaa street)),
          Address(Street(Bbb street)),
          Address(Street(Bpp street))
        )
      )
    )
    

    Note that as in the Shapeless answer, you'll need to change your Employee definition to use List instead of Seq, or if you don't want to change your model, you could build that transformation into the Lens with an Iso[Seq[A], List[A]].