Scala - Type Bounds (Upper and Lower)

Upper type bounds restrict a type parameter to be a subtype of a specified type using the `<:` syntax. This constraint allows you to call methods defined on the upper bound type within your generic...

Key Insights

  • Upper bounds (T <: SuperType) constrain type parameters to subtypes of a given type, enabling safe access to supertype methods while maintaining type safety in covariant positions
  • Lower bounds (T >: SubType) constrain type parameters to supertypes of a given type, essential for contravariant positions and enabling flexible method overriding
  • Combining type bounds with variance annotations (+T, -T) creates powerful abstractions for collections, function types, and generic APIs that balance type safety with flexibility

Understanding Upper Type Bounds

Upper type bounds restrict a type parameter to be a subtype of a specified type using the <: syntax. This constraint allows you to call methods defined on the upper bound type within your generic code.

trait Animal {
  def name: String
  def makeSound(): String
}

class Dog(val name: String) extends Animal {
  def makeSound(): String = "Woof"
  def fetch(): String = s"$name is fetching"
}

class Cat(val name: String) extends Animal {
  def makeSound(): String = "Meow"
  def scratch(): String = s"$name is scratching"
}

// T must be Animal or a subtype of Animal
class AnimalShelter[T <: Animal](animals: List[T]) {
  def getAllSounds(): List[String] = animals.map(_.makeSound())
  
  def findByName(name: String): Option[T] = 
    animals.find(_.name == name)
}

val dogShelter = new AnimalShelter[Dog](List(
  new Dog("Rex"),
  new Dog("Buddy")
))

val sounds = dogShelter.getAllSounds()
// List("Woof", "Woof")

val foundDog: Option[Dog] = dogShelter.findByName("Rex")
// Some(Dog("Rex"))

Upper bounds are particularly useful when you need to guarantee that certain methods or properties are available on the type parameter. Without the bound, the compiler wouldn’t allow you to call makeSound() or access name on elements of type T.

Upper Bounds with Multiple Constraints

Scala allows you to specify multiple upper bounds using the with keyword, requiring the type parameter to extend multiple traits.

trait Identifiable {
  def id: Long
}

trait Serializable {
  def serialize(): String
}

trait Auditable {
  def auditLog(): String
}

// T must extend both Identifiable and Serializable
class Repository[T <: Identifiable with Serializable] {
  private var storage = Map.empty[Long, T]
  
  def save(entity: T): Unit = {
    storage = storage + (entity.id -> entity)
    println(s"Saved: ${entity.serialize()}")
  }
  
  def findById(id: Long): Option[T] = storage.get(id)
}

case class User(id: Long, username: String, email: String) 
  extends Identifiable with Serializable {
  def serialize(): String = s"User($id,$username,$email)"
}

case class Product(id: Long, name: String, price: Double)
  extends Identifiable with Serializable {
  def serialize(): String = s"Product($id,$name,$price)"
}

val userRepo = new Repository[User]
userRepo.save(User(1, "john_doe", "john@example.com"))
// Saved: User(1,john_doe,john@example.com)

val productRepo = new Repository[Product]
productRepo.save(Product(100, "Laptop", 999.99))
// Saved: Product(100,Laptop,999.99)

This pattern is common in domain-driven design where entities must satisfy multiple contracts to be stored or processed by a particular component.

Understanding Lower Type Bounds

Lower type bounds constrain a type parameter to be a supertype of a specified type using the >: syntax. This is less intuitive than upper bounds but critical for contravariant positions and method overriding.

class Box[+A](val value: A) {
  // Without lower bound, this wouldn't compile with covariant A
  // B must be a supertype of A
  def put[B >: A](newValue: B): Box[B] = new Box(newValue)
}

val dogBox: Box[Dog] = new Box(new Dog("Max"))

// We can put an Animal (supertype of Dog) in a Box[Dog]
val animalBox: Box[Animal] = dogBox.put(new Cat("Whiskers"))

// This works because Cat is also an Animal
println(animalBox.value.makeSound())
// Meow

Lower bounds solve a fundamental problem with covariant type parameters. When you have a covariant type Box[+A], you cannot use A in contravariant positions (like method parameters) without lower bounds. The lower bound B >: A ensures type safety by requiring the new type to be a supertype of the original.

Lower Bounds in Collection Operations

Lower bounds are extensively used in Scala’s collection library, particularly in methods that build new collections.

trait Stack[+A] {
  def push[B >: A](element: B): Stack[B]
  def pop(): (A, Stack[A])
  def isEmpty: Boolean
}

class ConcreteStack[+A](private val elements: List[A]) extends Stack[A] {
  def push[B >: A](element: B): Stack[B] = 
    new ConcreteStack(element :: elements)
  
  def pop(): (A, Stack[A]) = elements match {
    case head :: tail => (head, new ConcreteStack(tail))
    case Nil => throw new NoSuchElementException("Empty stack")
  }
  
  def isEmpty: Boolean = elements.isEmpty
}

object Stack {
  def empty[A]: Stack[A] = new ConcreteStack[A](Nil)
}

val intStack: Stack[Int] = Stack.empty[Int]
  .push(1)
  .push(2)
  .push(3)

// We can push an Any (supertype of Int) onto an Int stack
val anyStack: Stack[Any] = intStack.push("hello")

val (top, rest) = anyStack.pop()
println(top) // "hello"

This pattern allows you to widen the type of a collection when adding elements, maintaining type safety while providing flexibility.

Combining Variance with Type Bounds

Type bounds become powerful when combined with variance annotations. Understanding this interaction is crucial for designing flexible APIs.

// Covariant in Out, contravariant in In
trait Function1[-In, +Out] {
  def apply(input: In): Out
  
  // Compose with lower bound on In1 (contravariant position)
  def compose[In1 <: In](g: Function1[In1, In]): Function1[In1, Out] = {
    (x: In1) => this.apply(g.apply(x))
  }
  
  // AndThen with upper bound on Out1 (covariant position)
  def andThen[Out1 >: Out](g: Function1[Out, Out1]): Function1[In, Out1] = {
    (x: In) => g.apply(this.apply(x))
  }
}

class Converter[-In, +Out](f: In => Out) extends Function1[In, Out] {
  def apply(input: In): Out = f(input)
}

val intToString = new Converter[Int, String](_.toString)
val stringLength = new Converter[String, Int](_.length)

val composed = stringLength.compose(intToString)
println(composed.apply(12345)) // 5

val chained = intToString.andThen(stringLength)
println(chained.apply(12345)) // 5

Practical Example: Type-Safe Builder Pattern

Here’s a real-world example combining upper and lower bounds to create a type-safe query builder.

sealed trait Query[+T]

case class Select[T](table: String, filter: T => Boolean) extends Query[T]
case class Join[T, U](left: Query[T], right: Query[U]) extends Query[(T, U)]

class QueryBuilder[+T](private val query: Query[T]) {
  def where[U >: T](predicate: U => Boolean): QueryBuilder[U] = {
    query match {
      case Select(table, existingFilter) =>
        new QueryBuilder(Select(table, (x: U) => 
          existingFilter.asInstanceOf[U => Boolean](x) && predicate(x)
        ))
      case _ => this.asInstanceOf[QueryBuilder[U]]
    }
  }
  
  def join[U](other: QueryBuilder[U]): QueryBuilder[(T, U)] = {
    new QueryBuilder(Join(this.query, other.query))
  }
  
  def build(): Query[T] = query
}

object QueryBuilder {
  def from[T](table: String): QueryBuilder[T] = 
    new QueryBuilder(Select(table, (_: T) => true))
}

case class User(id: Int, name: String, age: Int)
case class Order(userId: Int, amount: Double)

val userQuery = QueryBuilder.from[User]("users")
  .where(_.age > 18)
  .where(_.name.startsWith("J"))

val orderQuery = QueryBuilder.from[Order]("orders")
  .where(_.amount > 100.0)

val joinedQuery = userQuery.join(orderQuery)

val finalQuery: Query[(User, Order)] = joinedQuery.build()

Type bounds enable sophisticated generic programming patterns while maintaining compile-time type safety. Upper bounds let you access known methods on type parameters, while lower bounds enable flexible APIs with covariant types. Mastering these concepts is essential for building robust, reusable Scala libraries.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.