Scala bounds and variance

I've been learning Scala lately. One topic that I found a bit hard to comprehend is bounds and variance in the Scala type system. I want to document a real use case that came into my mind, that might be helpful to anyone in the future trying to understand the type system in Scala.

A typed plugin system.

Imagine you’re building a payment platform. 1. We're going to support different payment methods. 2. Each payment method has its own request type. 3. You want a generic processor that: (a). Accepts only valid payment requests (b). Can be extended safely (c). Doesn’t require rewriting logic everywhere

trait PaymentRequest {
  def amount: BigDecimal
}

final case class CardPayment(
  amount: BigDecimal,
  cardNumber: String
) extends PaymentRequest

final case class BankTransfer(
  amount: BigDecimal,
  iban: String
) extends PaymentRequest

Now let's have a processor:

trait Processor[T] {
  def process(request: T): Unit
}

This, however, allows any type to be called with. Even a string or an int. What we can do is to use boundaries to restrict T to payment requests:

trait Processor[T <: PaymentRequest] {
  def process(request: T): Unit
}

Now any other call with something that is not extending PaymentRequest becomes illegal.

Then use your concrete processors:

class CardProcessor extends Processor[CardPayment] {
  def process(request: CardPayment): Unit =
    println(s"Charging card ${request.cardNumber} for ${request.amount}")
}

class BankProcessor extends Processor[BankTransfer] {
  def process(request: BankTransfer): Unit =
    println(s"Transferring ${request.amount} to ${request.iban}")
}

Adding variance

Now you want a generic handler that can work with any payment processor:

def handle(
  processor: Processor[PaymentRequest],
  request: PaymentRequest
): Unit =
  processor.process(request)

This fails. Processor[CardPayment] is not a subtype of Processor[PaymentRequest], although CardPayment, is of PaymentRequest. Let's abstract to the fact that: a processor consumes T, which means: processor of general payments can handle specific payments. This is declared like following:

trait Processor[-T <: PaymentRequest] {
  def process(request: T): Unit
}

This is saying; If A is a subtype of B, then Processor[B] is a subtype of Processor[A].

Now this paramter is valid:

val genericProcessor: Processor[PaymentRequest] =
  new Processor[PaymentRequest] {
    def process(request: PaymentRequest): Unit =
      println(s"Processing payment of ${request.amount}")
  }

handlePayment(genericProcessor, CardPayment(100, "1234"))
handlePayment(genericProcessor, BankTransfer(250, "IBAN123"))

Consumers and Producers laws

If a type only CONSUMES T → use -T

Processor[-T]
Function1[-A, +B]
Comparator[-T]

This is known as The consumer rule

Producer laws

List[+T]
Option[+T]

If it happens to be both, then you're dealing with an invariant and you're going to populate data.

Full Example

trait PaymentRequest {
  def amount: BigDecimal
}

case class CardPayment(
  amount: BigDecimal,
  cardNumber: String
) extends PaymentRequest

case class BankTransfer(
  amount: BigDecimal,
  iban: String
) extends PaymentRequest

trait Processor[-T <: PaymentRequest] {
  def process(request: T): Unit
}

class CardProcessor extends Processor[CardPayment] {
  def process(request: CardPayment): Unit =
    println(s"Charging card ${request.cardNumber} for ${request.amount}")
}

class BankProcessor extends Processor[BankTransfer] {
  def process(request: BankTransfer): Unit =
    println(s"Transferring ${request.amount} to ${request.iban}")
}

class GenericProcessor extends Processor[PaymentRequest] {
  def process(request: PaymentRequest): Unit =
    println(s"Processing payment of ${request.amount}")
}

class LoggingProcessor[T <: PaymentRequest](
  underlying: Processor[T]
) extends Processor[T] {
  def process(request: T): Unit = {
    println("LOGGING")
    underlying.process(request)
  }
}

object PaymentApp extends App {

  val card = CardPayment(100, "1234-5678")
  val bank = BankTransfer(250, "IBAN-XYZ")

  val genericProcessor = new GenericProcessor

  val loggingCardProcessor =
    new LoggingProcessor[CardPayment](genericProcessor)

  val loggingBankProcessor =
    new LoggingProcessor[BankTransfer](genericProcessor)

  loggingCardProcessor.process(card)
  // println()
  loggingBankProcessor.process(bank)
}

#Programming #Scala