Generics & Bounds

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). The same programs and error demos were also compiled with 3.9.0 (the current LTS) and 3.3.8 (the previous LTS); the one difference, in the handling of the _ wildcard, is called out where it occurs.

A generic class, trait or method has one or more type parameters: placeholders for types that the caller chooses. They let one definition (Stack[A], Repository[K, V]) work for many element types while the compiler still checks every use. A bound restricts which types a parameter may take, and so also what the code inside the definition may do with a value of that type. This page covers the parameters and bounds themselves; how the variance annotations +A and -A make Route[Path] a Route[Segment] is the subject of Variance, how the compiler fills in type arguments is covered in Type Inference, and type parameters that are themselves type constructors (F[_]) are in Type Lambdas & Higher Kinds. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails. For the other JVM languages see Java: Generics and Kotlin: Generics & Variance.

Generic classes and traits

Type parameters go in square brackets after the name of a class, trait or enum. Scala uses [] where Java and Kotlin use <>, which frees < and > to be ordinary operators. By convention a lone parameter is A (Scala’s standard library) or T, and several parameters are K, V, A, B, or the initials of their roles.

//> using scala 3.10.0

package trailhead

case class Trail(name: String, lengthKm: Double, ascentM: Int)

// A generic class: A is chosen at each use
class Stack[A](private val items: List[A] = Nil):
  def push(a: A): Stack[A] = Stack(a :: items)
  def pop: (A, Stack[A]) = (items.head, Stack(items.tail))
  def isEmpty: Boolean = items.isEmpty
  def size: Int = items.size
  override def toString = items.mkString("Stack(", ", ", ")")

// A generic trait with two parameters
trait Repository[K, V]:
  def find(key: K): Option[V]
  def all: List[V]

// A subclass can fix one parameter and keep the other open
class InMemory[V](data: Map[String, V]) extends Repository[String, V]:
  def find(key: String): Option[V] = data.get(key)
  def all: List[V] = data.values.toList

// A polymorphic method: the type parameter belongs to the method
def twice[A](a: A): List[A] = List(a, a)
def pairUp[A, B](a: A, b: B): (A, B) = (a, b)
def firstOr[A](xs: List[A], default: A): A = xs.headOption.getOrElse(default)

@main def run(): Unit =
  val s1 = Stack[Int]()                 // explicit type argument
  val s2 = s1.push(1).push(2)           // A = Int stays fixed
  println(s2)
  println(s2.pop._1)
  val trails = Stack(List(Trail("Ridge", 8.5, 900)))   // A inferred: List[Trail]
  println(trails.size)
  val repo = InMemory(Map("Ridge" -> Trail("Ridge", 8.5, 900)))
  println(repo.find("Ridge"))
  println(repo.find("Pass"))
  println(twice("x"))
  println(twice[Double](1))             // explicit: the Int literal becomes a Double
  println(pairUp(1, "a"))
  println(firstOr(List[Int](), 7))
Stack(2, 1)
2
1
Some(Trail(Ridge,8.5,900))
None
List(x, x)
List(1.0, 1.0)
(1,a)
7

The rules the example shows:

  • A type parameter belongs to the declaration that lists it. In Stack[A], A is in scope in the whole class body. The push method does not declare a type parameter of its own, so s1.push("x") on a Stack[Int] is a type error.

  • A class’s type arguments are inferred from the constructor arguments (Stack(List(...)) is a Stack[List[Trail]]) and new is not needed to create an instance (see Classes & Constructors). When the arguments say nothing, as with Stack(), you write the argument yourself: Stack[Int]().

  • A polymorphic method has its own parameters. The compiler infers them from the arguments (twice("x") is twice[String]), and you may give them explicitly, as in twice[Double](1), which also decides how the literal 1 is typed.

  • A subclass chooses what to do with each parameter of its parent. InMemory[V] fixes K to String and keeps V open, so Repository[String, Trail] and InMemory[Trail] describe the same service.

Without a bound, a type parameter has the bounds >: Nothing <: Any, so inside the definition the only things you can do with an A are the ones every object supports: store it, pass it on, compare it with ==, and call toString, hashCode and the other methods of Any. Bounds are how you ask for more.

Upper bounds

An upper bound A <: B says that A must be B or a subtype of B. Inside the definition the compiler then knows that every A has the members of B.

//> using scala 3.10.0

package trailhead

sealed trait Segment:
  def km: Double
case class Path(km: Double) extends Segment
case class Ridge(km: Double) extends Segment
case class Hut(km: Double, beds: Int) extends Segment

// Upper bound: every A must be a Segment, so km is available
def totalKm[A <: Segment](xs: List[A]): Double = xs.map(_.km).sum

// The bound keeps the precise type: the result is an A, not just a Segment
def longest[A <: Segment](xs: List[A]): A = xs.maxBy(_.km)

@main def run(): Unit =
  val paths = List(Path(2.0), Path(5.0))
  println(totalKm(paths))
  val l = longest(paths)                 // Path, not Segment
  println(l)
7.0
Path(5.0)

The second method is the point of using a bound rather than an ordinary parameter. def longest(xs: List[Segment]): Segment accepts the same argument but returns a Segment, forcing the caller to match on it to recover a Path. With A <: Segment the result type is whatever the caller passed in. A type argument that violates the bound is rejected at the call, with the error code E057:

//> using scala 3.10.0

sealed trait Segment:
  def km: Double
case class Path(km: Double) extends Segment

def totalKm[A <: Segment](xs: List[A]): Double = xs.map(_.km).sum

@main def run(): Unit =
  println(totalKm[String](Nil))
./C.scala:11:19
Type argument String does not conform to upper bound Segment
  println(totalKm[String](Nil))
                  ^

When the type argument is inferred rather than written, the same mistake is reported at the offending argument:

./C.scala:11:24
Found:    ("a" : String)
Required: Segment
  println(totalKm(List("a", "b")))
                       ^^^

Lower bounds

A lower bound B >: A says that B must be A or a supertype of A. It looks odd until you meet a covariant class. A Route[+A] that lets you add a segment must not take an A, because A is covariant (it only appears in output positions). Taking an A as input is an error:

//> using scala 3.10.0
class Route[+A](val segments: List[A]):
  def prepend(b: A): Route[A] = Route(b :: segments)
./H.scala:3:15
covariant type A occurs in contravariant position in type A of parameter b
  def prepend(b: A): Route[A] = Route(b :: segments)
              ^^^^

The way out is a new type parameter that is allowed to be broader than A, and a result that is typed with it:

//> using scala 3.10.0

package trailhead

sealed trait Segment:
  def km: Double
case class Path(km: Double) extends Segment
case class Ridge(km: Double) extends Segment
case class Hut(km: Double, beds: Int) extends Segment

class Route[+A](val segments: List[A]):
  def prepend[B >: A](b: B): Route[B] = Route(b :: segments)

@main def run(): Unit =
  val paths = List(Path(2.0), Path(5.0))
  val r = Route(paths).prepend(Hut(0.5, 12))
  println(r.segments)
List(Hut(0.5,12), Path(2.0), Path(5.0))

What the compiler infers for B depends on the argument. The types of three calls on a Route[Path], revealed with the Nothing-assignment technique of Type Inference:

Call Result type

prepend(Path(2.0))

Route[Path]

prepend(Hut(0.5, 12))

Route[Segment]

prepend("oops")

Route[Path | String]

Adding a Path keeps the narrow type, adding a Hut widens to the common parent, and adding something unrelated widens to a union type (see Union & Intersection Types). The List class makes the same move in its own +:, :: and prepended methods, which is why 1 :: List("a") is a List[String | Int]. If you want to refuse unrelated elements, combine the lower bound with an upper bound.

Combining bounds

A parameter can have a lower and an upper bound together, written in that order. Bounds can also use the compound types of Scala 3.

//> using scala 3.10.0

package trailhead

sealed trait Segment:
  def km: Double
case class Path(km: Double) extends Segment
case class Ridge(km: Double) extends Segment
case class Hut(km: Double, beds: Int) extends Segment

// A context bound: an Ordering[A] must exist
def bestOf[A: Ordering](xs: List[A]): A = xs.max

// An upper bound and a context bound together
def widest[A <: CharSequence: Ordering](xs: List[A]): A = xs.max

// Both bounds on one parameter: a Segment that is at least a Path
def describe[A >: Path <: Segment](a: A): String = s"${a.km} km"

// F-bounded: A can be compared with its own kind
def largest[A <: Comparable[A]](xs: List[A]): A = xs.reduce((l, r) => if l.compareTo(r) >= 0 then l else r)

// An intersection as the upper bound
def arity[A <: Segment & Product](a: A): Int = a.productArity

// A union as the upper bound
def shout[A <: Int | String](a: A): String = a.toString.toUpperCase

// Singleton keeps the literal type
def literal[A <: Singleton](a: A): A = a

@main def run(): Unit =
  println(bestOf(List(3, 9, 4)))
  println(widest(List("ab", "abc")))
  println(describe(Path(1.0)))
  println(describe(Ridge(2.0)))
  println(largest(List("alpha", "gamma", "beta")))
  println(arity(Hut(1.0, 4)))
  println(shout(1))
  println(shout("a"))
  println(literal(42))
  println(1 :: List("a"))
9
abc
1.0 km
2.0 km
gamma
2
1
A
42
List(1, a)

The forms that can appear, with an example of each:

Form Meaning

A >: Path <: Segment

Both bounds: A is a Segment and a supertype of Path. describe[Ridge](...) fails with "Type argument Ridge does not conform to lower bound Path".

A <: Comparable[A]

An F-bounded (self-referential) bound: an A that can be compared with another A. Java’s <T extends Comparable<T>>.

A <: Segment & Product

An intersection as bound: an A that is both. Java and Kotlin spell this <T extends Segment & Product> and where T : Segment, T : Product.

A <: Int | String

A union as bound, which has no Java or Kotlin equivalent.

A <: Singleton

A must be a singleton type, so the literal’s type is kept: literal(42) has type 42, not Int.

A <: AnyRef

A is a reference type; A <: AnyVal is rarely needed.

A: Ordering

A context bound, shorthand for an extra (using Ordering[A]) parameter (see below).

Source: Scala 3 Book: Generics; the singleton bound is in the Scala API: scala.Singleton

Bounds versus context bounds

A bound constrains the inheritance of the type: A <: Segment needs A to extend Segment. A context bound A: Ordering constrains something else, the existence of a type-class instance Ordering[A] that the compiler finds at the call site. The two compose: A <: CharSequence: Ordering is both. Prefer a context bound when the capability comes from outside the type, as with Ordering[Int], because Int cannot be made to extend a trait you invent, and prefer an upper bound when the type already carries the operations. The details are in Context Bounds & Deferred Givens and Type Classes.

Wildcards

A wildcard stands for an unknown type argument at a use site: List[?] is a list of something. It can carry its own bounds, and these are the same as Java’s ? extends and ? super:

Scala Java Kotlin

List[?]

List<?>

List<*>

Seq[? <: CharSequence]

List<? extends CharSequence>

List<out CharSequence>

java.util.List[? >: Integer]

List<? super Integer>

MutableList<in Int>

//> using scala 3.10.0

def sizes(xs: List[?]): Int = xs.size
def paths(cs: Seq[? <: CharSequence]): List[Int] = cs.map(_.length).toList
def sink(xs: java.util.List[? >: Integer]): Unit = xs.add(1)

@main def run(): Unit =
  println(sizes(List(1, "a", 2.0)))
  println(paths(List("ab", StringBuilder("cde"))))
  val l = java.util.ArrayList[Number]()
  sink(l)
  println(l)
3
List(2, 3)
[1]

Wildcards are used less in Scala than in Java, because Scala’s collections are covariant (a Seq[Path] is already a Seq[Segment]) and a method can introduce a type parameter when it needs one. They matter for Java generics, which are invariant and rely on wildcards to express variance, and for mutable Java collections like java.util.List. The sink method above accepts a java.util.List[Number], java.util.List[Integer] and java.util.List[Object], which is exactly the Java List<? super Integer>. In the opposite direction, Seq[? <: CharSequence] is, for a covariant Seq, the same as Seq[CharSequence].

The _ spelling is deprecated

Scala 2 and early Scala 3 wrote List[_], Seq[_ <: CharSequence]. The _ was reassigned in Scala 3.4: it will in future be the placeholder for an anonymous type parameter (like f(_) for a lambda), which is why wildcards moved to ?. Under 3.9.0 and 3.10.0 the old spelling still compiles with a warning:

[warn] ./D.scala:7:18
[warn] `_` is deprecated for wildcard arguments of types: use `?` instead
[warn] This construct can be rewritten automatically under -rewrite -source 3.4-migration.
[warn] def old(xs: List[_]): Int = xs.size
[warn]                  ^

Compiled with -source:future, the same line is an error. The compiler flags -rewrite -source 3.4-migration rewrite the code. On 3.3.8 there is no warning, because _ has not been deprecated yet in the 3.3 LTS line, and ? is accepted there too, so code that is written with ? compiles on every Scala 3 version. Scala 2 does not know ?, so cross-built code must keep _ until it drops Scala 2.

Existential types are gone

Scala 2 also had existential types spelled forSome: List[T] forSome { type T }. Scala 3 removed them. A wildcard covers the common cases, and the rest are expressed with a dependent type or a type member:

./G.scala:2:21
Existential types are no longer supported -
use a wildcard or dependent type instead
def old(xs: List[T] forSome { type T }): Int = xs.size
                    ^^^^^^^

Evidence: =:= and <:<

Sometimes a method makes sense only for some type arguments of its class. A Leg[A] might have a sum only when A is Int. A bound on the method cannot express that, because the class parameter is already fixed. The solution is an evidence parameter: a given instance of the type A =:= B (A equals B) or A <:< B (A conforms to B). The compiler creates one when the relation holds and reports an error when it does not, so the method simply does not apply to the wrong types.

//> using scala 3.10.0

package trailhead

// A method that exists only when the type argument allows it
class Leg[A](val items: List[A]):
  def sum(using ev: A =:= Int): Int = items.map(ev).sum
  def flatten[B](using ev: A <:< List[B]): List[B] = items.flatMap(ev)
  def widen[B](using ev: A <:< B): List[B] = items.map(ev)

def sameType[A, B](using A =:= B): String = "same"
def subtype[A, B](using A <:< B): String = "subtype"

@main def run(): Unit =
  println(Leg(List(1, 2, 3)).sum)
  println(Leg(List(List(1), List(2, 3))).flatten)
  println(Leg(List("a", "b")).widen[Any])
  println(sameType[Int, Int])
  println(subtype[String, CharSequence])
  println(subtype[List[String], Seq[CharSequence]])
  // The standard library uses the same idea
  println(Option(Option(3)).flatten)
  println(List((1, "a"), (2, "b")).toMap)
6
List(1, 2, 3)
List(a, b)
same
subtype
subtype
Some(3)
Map(1 -> a, 2 -> b)

Both relations are classes with an apply, so the evidence ev is also a function: items.map(ev) converts an A to an Int, and that conversion is how the body gets to use the Int operations. Calling sum on a Leg[String] gives:

./E.scala:6:34
Cannot prove that String =:= Int.
  println(Leg(List("a", "b")).sum)
                                 ^

Option.flatten and Iterable.toMap in the standard library are written this way: flatten needs A <:< Option[B], and toMap needs A <:< (K, V). Scala 2 spelled the parameter (implicit ev: A =:= Int); the Scala 3 form uses using, and the old form still compiles on 3.10.0.

Erasure and what generics cannot do

The JVM does not keep type arguments at run time. List[Int] and List[String] are both List after compilation. This erasure is the same as in Java and Kotlin and it has the same consequences, which Scala handles in its own way.

//> using scala 3.10.0
import scala.reflect.ClassTag

def isListOfInt(x: Any): Boolean = x match
  case _: List[Int] => true
  case _ => false

def make[A: ClassTag](n: Int): Array[A] = new Array[A](n)

@main def run(): Unit =
  println(isListOfInt(List("not", "ints")))
  println(make[Int](3).toList)
  println(List(1).getClass == List("a").getClass)
[warn] ./F.scala:5:8
[warn] the type test for List[Int] cannot be checked at runtime because its type arguments can't be determined from Any
[warn]   case _: List[Int] => true
[warn]        ^
true
List(0, 0, 0)
true

The compiler warns that case _: List[Int] is unchecked, and the program then answers true for a list of strings. The two runtime classes are the same. In the same way a type parameter cannot be tested or instantiated:

//> using scala 3.10.0
def create[A](): A = new A()
def check[A](x: Any): Boolean = x.isInstanceOf[A]
./F.scala:2:26
A does not have a constructor
def create[A](): A = new A()
                         ^

The second definition compiles with a warning ("the type test for A cannot be checked at runtime because it refers to an abstract type member or type parameter"), and check[String](1) returns true. The remedies are:

  • ClassTag[A] (a context bound [A: ClassTag]) carries the runtime class: it is what lets make[Int](3) build an Array[Int], since arrays are the one place where the JVM does keep the element type.

  • A type class, as in def create[A](using f: Factory[A]), replaces new A() with a call to a factory that the caller or the type provides. This is the Scala way to construct things generically.

  • A TypeTest or a sealed hierarchy makes a pattern match checkable (see Pattern Matching).

  • A match on the shape that is checkable, such as case _: List[?], followed by a check of the elements.

Primitive type arguments are not a problem: List[Int] is allowed, and the compiler boxes the elements when the generic code needs objects, which is the same cost that Java pays for List<Integer>. Value classes and opaque types can avoid the boxing of your own wrapper types (see Opaque Types & Value Classes).

Java and Kotlin compared

Topic Scala 3 Java Kotlin

Syntax

class Box[A], def f[A](a: A)

class Box<A>, <A> A f(A a)

class Box<A>, fun <A> f(a: A)

Upper bound

A <: Segment

A extends Segment

A : Segment

Several bounds

A <: Segment & Product

A extends Segment & Product

where A : Segment, A : Product

Lower bound on a parameter

B >: A

not available (only on wildcards: ? super A)

not available

Wildcards

?, ? <: T, ? >: T

?, ? extends T, ? super T

*, out T, in T

Declaration-site variance

+A, -A

none

out A, in A

Primitive type argument

allowed (List[Int])

not allowed (List<Integer>)

allowed (List<Int>)

Runtime type tests

erased; ClassTag, TypeTest

erased; Class<T> arguments

erased; inline with reified for functions

Higher-kinded parameters

F[_]

none

none

Three differences are worth singling out. Scala has declaration-site variance, so a class says once that it is covariant instead of every user writing ? extends; Kotlin has the same, Java does not. Scala has lower bounds on ordinary type parameters (B >: A), which is what makes covariant collections with prepend-style methods possible; Java and Kotlin have only the wildcard forms. And Scala has no raw types: a class that takes a type parameter must be given one or be written with ?.

Where the books are outdated

  • List[_] and Seq[_ <: Foo] are the pre-3.4 spelling. Scala 3 writes List[?] and Seq[? <: Foo], and deprecates _. Many Scala 2 articles and Stack Overflow answers still use it.

  • Existential types written with forSome no longer exist in Scala 3.

  • Evidence parameters are shown as (implicit ev: A =:= Int) in Scala 2 books; in Scala 3 they are (using ev: A =:= Int). The older form still compiles.

  • The Tour of Scala examples use class Stack[A] with new Stack[Int]; Scala 3 allows Stack[Int]() without new.

  • The type-class style [A: Ordering] replaces (implicit ord: Ordering[A]) in newer code; See Context Bounds & Deferred Givens.

  • Articles that discuss type projections (Outer#Inner) as a general feature are out of date: Scala 3 supports them only when Outer is a class.

Scala 3.9 LTS versus 3.10

  • Generic classes, polymorphic methods, bounds, ? wildcards, =:=/<:< evidence and ClassTag behave the same on 3.3.8, 3.9.0 and 3.10.0: the programs and error demos above gave identical output.

  • The _ wildcard deprecation warning appears on 3.9.0 and 3.10.0 (it was introduced for Scala 3.4) and not on 3.3.8, and it is an error under -source:future on 3.10.0.