fp in scala: typesafe charges

🔗 posted 2022-08-09

🔙 back to index

I’m reading the second edition of Functional Programming in Scala and this is an example from the first chapter:

// CreditCard is not not defined the book so let's make something up
case class CreditCard(id: Long)

case class Charge(cc: CreditCard, amount: Double):
  def combine(other: Charge): Charge =
    if cc == other.cc
    then Charge(cc, amount + other.amount)
    else throw new Exception("Can't combine charges to different cards")

The book mentions we’ll come back around to that throw and replace it with functional error handling, likely with Either, but I recently watched a video on type-level operations in Scala and wondered if I could write a version of this that is entirely free of runtime errors.

Turns out we can if we give CreditCard a phantom type parameter and a helper to construct it:

case class CreditCard[T](id: Long)
object CreditCard:
  def apply[T](id: Long): CreditCard[n.type] =
    CreditCard(id)

case class Charge[T](cc: CreditCard[T], amount: Double):
  def combine(other: Charge[T]): Charge[T] =
    Charge(cc, amount + other.amount)

val charge = Charge(CreditCard(1), 1.0)

charge.combine(Charge(CreditCard(1), 2.0)) // works!
charge.combine(Charge(CreditCard(2), 2.0)) // ❌

If I toss this in a worksheet, I get the squiggles CreditCard(2), 2.0), telling me:

Found:    App.this.CreditCard[(2L : Long)]
Required: App.this.CreditCard[(1L : Long)]

Is this a good idea?

I’m not sure! Probably depends on the specific domain, how things are being used, and what’s knowable at compiletime vs runtime. I can think of at least one way this makes things harder.

Suppose want to look up a credit card by a username:

def lookupByName[T](user: String): CreditCard[T] =
  new CreditCard(9) // dummy implementation

We’re required to use new here because CreditCard(9) makes a CreditCard[(9L : Long)], not a CreditCard[T]. Using that new leaves us with a CreditCard[Nothing], which is compatible with every other CreditCard that is looked up by name!

This problem is not insurmountable, we could always add more indirection and abstraction, like maybe GenericCreditCard that can be promoted into a CreditCard[T]. This makes the technique more cognitively expensive, though, and it’s not clearly better than just using an Either.