Bulletin: Treat Your Data to the Latest Updates

Written by Dave Gurnell

Bulletin is a simple but useful library for merging updates into case classes. You create two classes, one representing a data record and one a partial update. Bulletin type-checks the classes and automatically generates a function to merge them.


Suppose we’re writing an address book application that models our friends as instances of a case class Person. In addition to Person we define a case class PersonUpdate representing a partial update:

case class Person(
  id: Long,
  name: String,
  email: Option[String])

case class PersonUpdate(
  name: Option[String],
  email: Option[Option[String]])

val person = Person(123L, "Bruce Wayne", Some("bruce@waynemanor.com"))
val update = Update(Some("Batman"), Some(None))

With these two case classes in place, we can mechanically define a merge function by matching the field names and writing calls to getOrElse:

def mergePerson(person: Person, update: PersonUpdate): Person =
  person.copy(
    name  = update.name  getOrElse person.name,
    email = update.email getOrElse person.email
  )

Writing merge functions is tedious and violates the principle of DRY (“Don’t Repeat Yourself”). Bulletin avoids this problem by providing a single merge operation that works for all compatible case classes:

import bulletin._

val updated = person merge update
// updated: Person = Person(123L, "Batman", None)

How does it work? Bulletin uses a type class called Merge behind the scenes to ensure it can apply an update to a record:

trait Merge[A, B] {
  def apply(record: A, update: B): A
}

def merge[A, B](record: A, update: B)(implicit m: Merge[A, B]): A =
  m(record, update)

Bulletin uses shapeless to automatically define Merge instances for pairs of case classes. It requires that every field in the update class has a corresponding field of matching type in the record class. Otherwise you get a compile error:

person merge "This isn't an update!"
// compile error:
//   Cannot update a Person with a String.
//   Check the field types match up,
//   or manually create a Merge instance for these types.

If your data doesn’t form a neat pair of case classes, you can write a Merge instance by hand. It’s less convenient but it maintains the same interface for all merges:

implicit val pairMerge[A, B] = new Merge[(A, B), (Option[A], Option[B])] {
  def apply(record: (A, B), update: (Option[A], Option[B])): (A, B) = (
    update._1 getOrElse record._1,
    update._2 getOrElse record._2
  )
}

val updated = (123, 234) merge (Option.empty[Int], Option(345))
// updated: (Int, Int) = (123, 345)

You can grab Bulletin via SBT. Check the README on GitHub for details.