Write generic classes and methods with type parameters and let the compiler infer them
Explain covariance, contravariance and invariance, and read the compiler errors they produce
Constrain type parameters with upper and lower bounds
Add methods to existing types with extension methods
Build a type class with a trait, given instances and using clauses, and use context bounds such as [A: Ordering]
01
Type parameters on classes and methods
✓
A type parameter lets one piece of code work for many types while staying fully type-checked. It goes in square brackets: class Box[A], def first[A](xs: List[A]): A. A is a placeholder the caller fills in — usually without writing it, because the compiler infers it from the arguments. You have used generic types since Module 06: List[Int] is List with A = Int.
scalaMain.scala
12345678910111213141516171819
caseclassBox[A](value: A):def map[B](f: A => B):Box[B]=Box(f(value))caseclassPair[A, B](first: A, second: B):def swap:Pair[B, A]=Pair(second, first)def firstOr[A](xs:List[A], default: A): A =
xs.headOption.getOrElse(default)@maindefrun():Unit=val b =Box(21)println(b.map(_ *2))println(b.map(n => s"#$n"))println(Pair("id",7).swap)println(firstOr(List(3,4),0))println(firstOr(List.empty[String],"none"))println(firstOr[Double](Nil,1.5))
Outputcompiled & run with real Scala
Box(42)
Box(#21)
Pair(7,id)
3
none
1.5
Nowhere did we write Box[Int] — the compiler inferred it from 21, and inferred B = String from the lambda in the second map. You can still pass type arguments explicitly, as the last line does, when there is nothing to infer from.
Your turn
Add a method def zip[B](other: Box[B]): Box[(A, B)] to Box and print Box(1).zip(Box("a")).
Type erasure
On the JVM, type arguments exist only at compile time. At runtime a List[Int] and a List[String] are both just List, so a pattern like case xs: List[Int] cannot really check the element type — the compiler warns you when you try. Match on the elements instead.
02
Variance: +A, -A and invariance
✓
If a Dog is an Animal, is a Box[Dog] a Box[Animal]? Variance is the answer, and you choose it with a mark on the type parameter:
Declaration
Name
Meaning
Standard examples
class Box[A]
Invariant
Box[Dog] and Box[Animal] are unrelated
Array, mutable.ArrayBuffer
class Box[+A]
Covariant
Box[Dog] is a Box[Animal]
List, Vector, Option, Either
class Box[-A]
Contravariant
Box[Animal] is a Box[Dog]
function parameters, Ordering-like consumers
Rule of thumb: a type that only producesAs (you read them out) can be covariant. A type that only consumesAs (you pass them in) can be contravariant. A type that does both — anything mutable — must stay invariant. A function A => B is both at once: contravariant in what it takes, covariant in what it returns.
A list of dogs can be used where a list of animals is expected, because List is covariant. A printer that can print any animal can certainly print a dog, so Printer[Animal] works as a Printer[Dog] — contravariance runs the other way. The lambdas are accepted for Source and Printer because each trait has exactly one abstract method.
Error you will hit
An invariant class does not follow subtyping
scala
12345678910
classAnimal(val name:String)classDog(name:String)extendsAnimal(name)classCage[A](var occupant: A)defdescribe(c:Cage[Animal]):String= c.occupant.name
@maindefrun():Unit=val c =Cage(Dog("Rex"))println(describe(c))
Cage has no variance mark, so it is invariant: Cage[Dog] is not a Cage[Animal]. That is correct here. If it were allowed, describe could do c.occupant = Animal("Cat") and your dog cage would now hold a cat.
The fix
Make the method generic with an upper bound (next lesson), so it accepts a cage of any kind of animal without being able to break it.
scala
12345678910
classAnimal(val name:String)classDog(name:String)extendsAnimal(name)classCage[A](var occupant: A)def describe[A <:Animal](c:Cage[A]):String= c.occupant.name
@maindefrun():Unit=val c =Cage(Dog("Rex"))println(describe(c))
Error you will hit
A covariant type parameter in a mutable field
scala
12345
classCage[+A](var occupant: A)@maindefrun():Unit=val c =Cage(42)println(c.occupant)
-- Error: Main.scala:1:19
1 |class Cage[+A](var occupant: A)
| ^^^^^^^^^^^^^^^
|covariant type A occurs in invariant position in type A of variable occupant
1 error found
Compilation failed
Why the compiler said that
A var field is both read (a getter that produces an A) and written (a setter occupant_=(a: A) that consumes one), so its type sits in an invariant position. If Cage were covariant, a Cage[Int] could be treated as a Cage[Any] and have a String stored in it. The compiler checks every place A appears and rejects the class.
The fix
Make the field a val (read-only data can be covariant), or drop the + and keep the class invariant.
scala
12345
classCage[+A](val occupant: A)@maindefrun():Unit=val c =Cage(42)println(c.occupant)
03
Upper and lower bounds
✓
A bound restricts which types a parameter accepts. An upper boundA <: Animal means "A must be Animal or a subtype", which lets the method use everything Animal has while still returning the precise type the caller passed in. A lower boundB >: A means "B must be A or a supertype" — it is how covariant collections still manage to accept new elements.
Because of the bound, byId may call _.id. And because it returns Option[A], not Option[Entity], the caller gets a User back and can read name without a cast.
scalaMain.scala
123456789101112131415
sealedtraitStack[+A]:def push[B >: A](b: B):Stack[B]=NonEmpty(b,this)caseobjectEmptyextendsStack[Nothing]caseclassNonEmpty[+A](top: A, rest:Stack[A])extendsStack[A]classFruit(val name:String):overridedef toString:String= name
classAppleextendsFruit("apple")classPearextendsFruit("pear")@maindefrun():Unit=val apples:Stack[Apple]=Empty.push(Apple())val fruits:Stack[Fruit]= apples.push(Pear())println(fruits)
Outputcompiled & run with real Scala
NonEmpty(pear,NonEmpty(apple,Empty))
push cannot take an A in a covariant class (that is a consumer position), so it takes any B that is a supertype of A and returns a Stack[B]. Pushing a pear onto apples gives a stack of fruit. List's :: is declared exactly this way, and Empty is a Stack[Nothing] — Nothing is a subtype of every type, just like Nil is a List[Nothing].
04
Extension methods
✓
An extension method adds a method to a type you do not own — String, Int, a library class — without subclassing or wrapping it. You write extension (x: Type) followed by one or more ordinary defs; inside them, x is the value the method was called on. Callers then use normal dot syntax. Module 04 used one to give an opaque type a value method.
scalaMain.scala
123456789101112131415161718
extension(s:String)def isPalindrome:Boolean=val clean = s.toLowerCase.filter(_.isLetter)
clean == clean.reverse
def words:List[String]= s.split("\\s+").toList.filter(_.nonEmpty)deftruncate(n:Int):String=if s.length <= n then s else s.take(n)+"..."extension(n:Int)def isEven:Boolean= n %2==0deftimes(action:=>Unit):Unit=for _ <-1 to n do action
@maindefrun():Unit=println("Never odd or even".isPalindrome)println(" handbooks are fun ".words)println("Extension methods".truncate(9))println(10.isEven)3.times(print("hi "))println()
Outputcompiled & run with real Scala
true
List(handbooks, are, fun)
Extension...
true
hi hi hi
Several methods can share one extension clause. times takes a by-name block, so 3.times(...) reads like a loop keyword. Under the hood the call "abc".truncate(2) is simply truncate("abc")(2), a static method call — no object is allocated.
Your turn
Add extension (xs: List[Int]) def average: Double that returns 0.0 for an empty list, and print List(2, 4, 9).average.
Extensions can be generic, and can target a specific shape of a generic type such as List[Int] or any Option[A]. They are only visible where they are in scope, so libraries put them in an object and you import the ones you want.
scalaMain.scala
123456789101112131415161718
objectCollectionSyntax:extension[A](xs:List[A])def second:Option[A]= xs.drop(1).headOption
defcountWhere(p: A =>Boolean):Int= xs.count(p)extension(xs:List[Int])def total:Int= xs.sum
extension[A](opt:Option[A])deforFail(msg:String):Either[String, A]= opt.toRight(msg)@maindefrun():Unit=importCollectionSyntax.*val xs =List(5,8,13)println(xs.second)println(xs.countWhere(_ >6))println(xs.total)println(List("a").second.orFail("no second element"))
Outputcompiled & run with real Scala
Some(8)
2
26
Left(no second element)
total exists only on List[Int]; calling it on a List[String] would not compile. Without the import, none of these methods would be found.
05
Type classes: trait, given and using
✓
Suppose you want a show method that turns values into user-facing text, for Int, for Boolean, for your own case classes — and for lists of any of those. You cannot add a parent trait to Int. A type class solves this in three parts:
1
The interface
A generic trait Show[A] with the operations, taking the value as a parameter.
2
Instances
A given for each type that supports it: given Show[Int] with .... The compiler records these in a table of "given" values, one per type.
3
Using it
Functions ask for an instance with a using parameter list. The caller does not pass it: the compiler finds the matching given by type and passes it automatically.
The fourth given is the powerful one: "if you can show an A, I can show a List[A]". The compiler buildsShow[List[Money]] out of Show[Money] for you. Adding support for a new type means adding one given; no existing code changes.
Your turn
Add a given Show[String] that wraps the text in quotes, then call display("names", List("Asha", "Ravi")).
VisualizeHow the compiler finds Show[List[Money]]Step 1 / 6
7def display[A](label: String, a: A)(using s: Show[A]): Unit =
8 println(s"$label: ${s.show(a)}")
9
10display("prices", List(Money(100), Money(250)))
Line 10
The argument is a List[Money], so the compiler infers A = List[Money] and needs a Show[List[Money]] for the using clause.
Variables now
A
List[Money]
All 6 steps as a table
Step
Line
What happened
Variables now
1
10
The argument is a List[Money], so the compiler infers A = List[Money] and needs a Show[List[Money]] for the using clause.
A = List[Money]
2
4
No given has exactly that type, but this generic given produces a Show[List[A]] for any A — with A = Money — provided it can get a Show[Money].
needed = Show[Money]
3
1
The Show[Money] given matches. The compiler now has everything and writes the call for you.
found = Show[Money]
4
10
At compile time the call has become, roughly, display("prices", list)(using listShow(using moneyShow)). Nothing is searched at runtime.
5
5
At runtime the list instance maps moneyShow.show over the elements and joins them.
6
8
The label and text are printed.
summon[Show[Int]] fetches the instance the compiler would pass — handy for calling it directly. Combined with an extension method inside the trait, it gives the type class ordinary method syntax: money.show. Put instances for your own types in the type's companion object, and the compiler finds them anywhere without an import.
Point's instance lives in its companion, so Point(2, 3).show works with no import at all. This is the shape real libraries use for JSON encoders, database mappers and orderings.
-- [E172] Type Error: Main.scala:11:22
11 | println(display(3.5))
| ^
|No given instance of type Show[Double] was found for parameter s of method display
1 error found
Compilation failed
Why the compiler said that
The using clause asks for a Show[Double], and the compiler searched the local scope, the imports and the companions of Show and Double without finding one. This is a compile-time error: a missing instance can never become a runtime surprise.
The fix
Provide the instance (or import the object that defines it).
Writing (using ord: Ordering[A]) on every generic method gets noisy. A context bound says the same thing in the type parameter list: def maxOf[A: Ordering](xs: List[A]) means "there must be a given Ordering[A]". Inside, you summon it when you need it. The standard library's sorted, max and sum are built exactly this way on Ordering and Numeric.
scalaMain.scala
1234567891011121314151617181920
def maxOf[A:Ordering](xs:List[A]):Option[A]=
xs.reduceOption((a, b)=>if summon[Ordering[A]].gt(a, b)then a else b)def total[A:Numeric](xs:List[A]): A =
xs.foldLeft(summon[Numeric[A]].zero)(summon[Numeric[A]].plus)caseclassRelease(major:Int, minor:Int)objectRelease:givenOrdering[Release]=Ordering.by(r =>(r.major, r.minor))@maindefrun():Unit=println(maxOf(List(3,9,2)))println(maxOf(List("pear","apple")))println(total(List(1.5,2.25)))println(total(List(BigInt(10),BigInt(20))))val releases =List(Release(2,13),Release(3,1),Release(2,9))println(releases.sorted)println(maxOf(releases))println(releases.sorted(usingOrdering[Release].reverse).head)
Ordering.by builds an ordering from a key — here a tuple, which compares field by field. Once Release has a given Ordering in its companion, sorted, max and our own maxOf all work on it. using at the call site passes an instance explicitly, overriding the given one.
Your turn
Sort the releases newest-first with sortBy(r => (-r.major, -r.minor)) and compare the result with the reverse ordering.
-- [E172] Type Error: Main.scala:5:19
5 | println(rs.sorted)
| ^
|No given instance of type Ordering[B] was found for parameter ord of method sorted in trait StrictOptimizedSeqOps.
|I found:
|
| scala.math.Ordering.ordered[B](
| /* missing */summon[scala.math.Ordering.AsComparable[B]])
|
|But no implicit values were found that match type scala.math.Ordering.AsComparable[B]
|
|where: B is a type variable with constraint >: Release
|.
|
|The following import might make progress towards fixing the problem:
|
| import scala.math.Ordered.orderingToOrdered
1 error found
Compilation failed
Why the compiler said that
sorted has a context bound on Ordering. Numbers and strings have a given Ordering in the standard library, but the compiler has no idea how two Releases compare — by major? by minor? — so it will not guess. The message says Ordering[B] because sorted is declared as sorted[B >: A]; the suggested import is a red herring here.
The fix
Sort by a key that already has an ordering, or define a given Ordering[Release] in the companion as in the example above.
Writing a given by hand for every case class gets repetitive. A derives clause asks the type class to generate the instance from the shape of the type: case class User(name: String, age: Int) derives Codec. The type class must support derivation, which libraries do for JSON codecs, equality, ordering and more. The standard library's built-in example is CanEqual, which works with strict equality to stop comparisons between unrelated types.
Under strictEquality, == only compiles when a CanEqual instance exists for the two types. derives CanEqual generates one for comparing a type with itself, so UserId(7) == 7 — a classic bug that normal == accepts and always answers false — becomes a compile error.
scala
1234
// with a JSON library such as circe or upickle on the classpathcaseclassOrder(id:Int, item:String, qty:Int) derives Codecval json =Order(1,"tea",3).asJson // encoder generated from the fields
Not runnable here (it needs a library), but this is the most common derives you will meet at work.
Type parameter
A placeholder type in square brackets, such as A in List[A], filled in by the caller or inferred.
Invariant
The default: Box[Dog] and Box[Animal] are unrelated types.
Covariant (+A)
Box[Dog] is a subtype of Box[Animal]; allowed when A is only produced, never consumed.
Contravariant (-A)
Box[Animal] is a subtype of Box[Dog]; allowed when A is only consumed.
Upper bound (A <: T)
A must be T or a subtype of T.
Lower bound (B >: A)
B must be A or a supertype of A; used to add elements to covariant types.
Extension method
A method added to an existing type from outside it, called with ordinary dot syntax.
Type class
A generic trait describing a capability, plus given instances that give specific types that capability.
given
A value the compiler can pass automatically to using parameters of its type.
using clause
A parameter list the compiler fills in with a matching given instance.
summon
Fetches the given instance of a type, as in summon[Ordering[Int]].
Context bound
Shorthand [A: TC] for a using parameter of type TC[A].
derives
A clause that asks a type class to generate its instance for a type automatically.
Quick check
Why can List[+A] be covariant but Array[A] cannot?
If Array[Dog] were an Array[Animal], code could store a Cat in it through the Array[Animal] view. Anything that consumes A — a setter, an update method — must be invariant. List never changes, so it is safe.
Quick check
What does def sortDesc[A: Ordering](xs: List[A]) require?
A context bound is shorthand for a using parameter of type Ordering[A]. The caller must have a given instance available, or the call does not compile.
A given declares a value the compiler may pass automatically, and a using clause declares a parameter the compiler should fill with a matching given by type. Together they replace Scala 2's implicit values and parameters, and they are how type classes such as Ordering and JSON codecs are supplied.
What is the difference between covariance and contravariance?
With covariance (+A), a Box[Dog] can be used as a Box[Animal]; it is safe for types that only give out values, like immutable List. With contravariance (-A), a Handler[Animal] can be used as a Handler[Dog]; it is safe for types that only take values in, like function parameters.
How do extension methods work in Scala 3?
You write extension (x: SomeType) def newMethod(...) = .... Wherever the extension is in scope, x.newMethod(...) compiles to a plain call of newMethod(x)(...). They replace Scala 2's implicit classes and are also how type classes offer method syntax.
Finish the Scala handbook, then get hired
Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.