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 PaymentRequestNow 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)
}