Scala - Generic Types (Type Parameters)
Type parameters in Scala allow you to write generic code that works with multiple types while maintaining type safety. Unlike Java's generics, Scala's type system is more expressive and integrates...
Key Insights
- Type parameters enable compile-time type safety while maintaining code reusability, preventing runtime type errors that plague dynamically typed languages
- Variance annotations (covariance
+T, contravariance-T, and invariance) control subtyping relationships between parameterized types, critical for designing flexible APIs - Context bounds and view bounds provide syntactic sugar for implicit parameters, enabling type class patterns without verbose parameter lists
Understanding Type Parameters
Type parameters in Scala allow you to write generic code that works with multiple types while maintaining type safety. Unlike Java’s generics, Scala’s type system is more expressive and integrates seamlessly with its functional programming features.
class Box[T](val value: T) {
def get: T = value
def map[U](f: T => U): Box[U] = new Box(f(value))
}
val intBox = new Box(42)
val stringBox = intBox.map(_.toString)
println(stringBox.get) // "42"
The Box[T] class is parameterized by type T. The compiler infers types automatically, eliminating the need for explicit type annotations in most cases. The map method introduces a second type parameter U, demonstrating how methods can have their own type parameters independent of the class.
Multiple Type Parameters
Classes and methods can accept multiple type parameters, useful for collections, tuples, and functional abstractions.
class Pair[A, B](val first: A, val second: B) {
def swap: Pair[B, A] = new Pair(second, first)
def map[C, D](f: A => C, g: B => D): Pair[C, D] =
new Pair(f(first), g(second))
}
val pair = new Pair("age", 30)
val swapped = pair.swap
val transformed = pair.map(_.toUpperCase, _ * 2)
println(s"${transformed.first}: ${transformed.second}") // "AGE: 60"
Type parameters maintain relationships between input and output types, ensuring type correctness across transformations.
Variance Annotations
Variance defines how subtyping between parameterized types relates to subtyping of their type arguments. This is crucial for building flexible, type-safe APIs.
Covariance (+T)
Covariant type parameters allow a parameterized type to vary in the same direction as its type argument. If Dog is a subtype of Animal, then Container[Dog] is a subtype of Container[Animal].
class Animal(val name: String)
class Dog(name: String) extends Animal(name)
class Cat(name: String) extends Animal(name)
class CovariantContainer[+T](val item: T) {
def get: T = item
// Cannot have contravariant position:
// def set(item: T): Unit = {} // Compilation error
}
def processAnimals(container: CovariantContainer[Animal]): Unit = {
println(container.get.name)
}
val dogContainer = new CovariantContainer(new Dog("Rex"))
processAnimals(dogContainer) // Works due to covariance
Covariant type parameters can only appear in output positions (return types), not input positions (method parameters), preventing type safety violations.
Contravariance (-T)
Contravariant type parameters vary in the opposite direction. If Dog is a subtype of Animal, then Processor[Animal] is a subtype of Processor[Dog].
trait Processor[-T] {
def process(item: T): Unit
}
class AnimalProcessor extends Processor[Animal] {
def process(item: Animal): Unit =
println(s"Processing animal: ${item.name}")
}
def processDog(processor: Processor[Dog], dog: Dog): Unit = {
processor.process(dog)
}
val animalProcessor = new AnimalProcessor
processDog(animalProcessor, new Dog("Max")) // Works due to contravariance
Contravariant type parameters appear in input positions. This makes sense: a processor that handles any Animal can certainly handle a Dog.
Invariance (No Annotation)
Without variance annotations, types are invariant. Container[Dog] and Container[Animal] have no subtyping relationship.
class InvariantContainer[T](private var item: T) {
def get: T = item
def set(item: T): Unit = { this.item = item }
}
// val animalContainer: InvariantContainer[Animal] =
// new InvariantContainer[Dog](new Dog("Buddy")) // Compilation error
Invariance is required when a type parameter appears in both input and output positions, maintaining type safety for mutable structures.
Type Bounds
Type bounds constrain type parameters to specific type hierarchies, enabling operations on the bounded types.
Upper Type Bounds
Upper bounds (T <: UpperBound) restrict type parameters to subtypes of a given type.
class Animal {
def makeSound: String = "Some sound"
}
class Dog extends Animal {
override def makeSound: String = "Woof"
}
class AnimalShelter[T <: Animal](animals: List[T]) {
def makeAllSoundsLoud: List[String] =
animals.map(_.makeSound.toUpperCase)
}
val dogShelter = new AnimalShelter(List(new Dog, new Dog))
println(dogShelter.makeAllSoundsLoud) // List("WOOF", "WOOF")
// val intShelter = new AnimalShelter(List(1, 2, 3)) // Compilation error
Upper bounds ensure that methods can safely call operations defined on the bound type.
Lower Type Bounds
Lower bounds (T >: LowerBound) restrict type parameters to supertypes of a given type, commonly used with covariant types.
class CovariantList[+T] {
def prepend[U >: T](item: U): CovariantList[U] =
new CovariantList[U]
}
val dogList: CovariantList[Dog] = new CovariantList[Dog]
val animalList: CovariantList[Animal] = dogList.prepend(new Cat("Whiskers"))
Lower bounds allow adding elements of supertypes to covariant collections, widening the type parameter appropriately.
Context Bounds and Type Classes
Context bounds provide syntactic sugar for implicit parameters, enabling the type class pattern.
trait Serializer[T] {
def serialize(value: T): String
}
implicit val intSerializer: Serializer[Int] = new Serializer[Int] {
def serialize(value: Int): String = value.toString
}
implicit val stringSerializer: Serializer[String] = new Serializer[String] {
def serialize(value: String): String = s""""$value""""
}
// Using context bound
def toJson[T: Serializer](value: T): String = {
val serializer = implicitly[Serializer[T]]
s"{ value: ${serializer.serialize(value)} }"
}
println(toJson(42)) // { value: 42 }
println(toJson("hello")) // { value: "hello" }
The notation [T: Serializer] is equivalent to [T](implicit ev: Serializer[T]). Context bounds reduce boilerplate when working with type classes.
Advanced Pattern: F-Bounded Polymorphism
F-bounded polymorphism enables recursive type constraints, useful for fluent APIs and ensuring method chaining returns the correct type.
trait Builder[T <: Builder[T]] {
def setName(name: String): T
def build(): String
}
class PersonBuilder extends Builder[PersonBuilder] {
private var name: String = ""
def setName(name: String): PersonBuilder = {
this.name = name
this
}
def setAge(age: Int): PersonBuilder = this
def build(): String = s"Person($name)"
}
val person = new PersonBuilder()
.setName("Alice")
.setAge(30) // Returns PersonBuilder, not generic Builder
.build()
This pattern ensures that methods return the concrete subclass type, enabling proper method chaining with subclass-specific methods.
Practical Considerations
Type parameters add compile-time overhead but no runtime cost—Scala uses type erasure like Java. For performance-critical code with primitives, consider specialized implementations or value classes.
Always prefer immutable designs with covariant type parameters for collections. Use invariance for mutable structures to prevent heap pollution. Apply contravariance to function inputs and consumer interfaces.
When designing APIs, start with invariant types and add variance annotations only when subtyping relationships provide clear value. Overly complex variance annotations can confuse users and complicate implementations.