Scala's type system is not a thin layer over the JVM's. It is a second, much richer lattice that the compiler reasons inside and then erases down to plain Java classes at the bytecode boundary, and most of the confusion around Scala types lives in that gap. This article works through the parts that carry weight in production code: the unified hierarchy and the two types sitting underneath it, type parameters and the variance rules that decide which substitutions are legal, bounds, higher-kinded parameters, the type-member axis that runs parallel to type parameters, the Scala 3 additions, and the places where inference deliberately stops. Contextual abstraction - implicits, givens, type classes - is referenced here but treated in its own articles.
The hierarchy, and the two types at the bottom
Every Scala type descends from Any, which declares ==, !=, ##, isInstanceOf and asInstanceOf. That single root is why an Int has methods and why generic code never has to care whether it was handed a primitive or an object; the primitive/reference split that Java exposes in its type system is pushed down one level. Any has exactly two children. AnyVal covers the nine built-in value types - Byte, Short, Int, Long, Float, Double, Char, Boolean, Unit - plus any user-defined value class. AnyRef is java.lang.Object under a different name, and everything you declare with class or trait lands there by default.
Underneath sit two types with no ordinary values. Null is a subtype of every AnyRef, which is precisely why null type-checks where a String is wanted and is rejected where an Int is wanted. Nothing is a subtype of every type, including Null, including Int, and it has no values at all.
Treating Nothing as trivia is a mistake, because a bottom type is what lets several very ordinary things work. A throw expression has type Nothing, so if (ok) 1 else throw new IllegalStateException has type Int rather than some awkward join of Int and an exception type. Nil is a List[Nothing] and None is an Option[Nothing]; combined with covariance, one singleton empty value serves every element type, where without a bottom type you would need a distinct empty list per element type or an empty list that fits nowhere. A method declared def loop(): Nothing states in its signature that it never returns normally, and the compiler treats the code after a call to it as unreachable. The cost of all this arrives later: because Nothing conforms to everything, an inference that bottoms out at Nothing never complains where it happened.
Type parameters, and what survives to the bytecode
A type parameter is a hole in a declaration that the use site fills: class Box[A](val value: A), def firstOr[A](xs: List[A], fallback: A): A. Scala's syntax uses square brackets rather than angle brackets, which frees < and > for the bound operators, and unlike Java the type argument at the call site is almost always inferred from the value arguments.
At the bytecode boundary the parameters are erased. Box[String] and Box[Int] compile to one class, List[String] and List[Int] are the same runtime class, and the compiler inserts casts at the boundaries where the static type promised something the bytecode cannot. Three consequences bite regularly. A pattern match on case xs: List[String] tests only that the value is a List, and the compiler warns that the element type is unchecked. Two overloads whose parameter lists differ only inside a type argument collide with the familiar "have the same type after erasure" rejection. And Box[Int] stores a boxed java.lang.Integer unless the class is specialized. The machinery for recovering type information at runtime - ClassTag, TypeTag, Scala 3's TypeTest - belongs to Scala reflection and is covered there.
One classical use of a bound is F-bounded polymorphism, trait Node[A <: Node[A]] { def self: A }, which lets an inherited method return the precise subtype instead of the base type. It works, but nothing stops a subclass from passing a sibling's type as A, so the guarantee is weaker than it looks; a type class or an abstract type member is usually the more honest encoding.
Variance: what the annotation actually promises
By default a type parameter is invariant. Given class Box[A] and Cat <: Animal, the types Box[Cat] and Box[Animal] have no subtyping relationship in either direction, even though their element types do. A variance annotation is how you ask for one: +A says Box[Cat] is a Box[Animal], -A says the opposite, Box[Animal] is a Box[Cat]. The annotation is written once on the declaration, so callers never repeat it in signatures.
The rule becomes intuitive as soon as you look at function types, which are declared trait Function1[-T1, +R]. Substitutability says a replacement must demand no more and promise no less than the thing it replaces. A function's parameters are its demands and its result is its promise. So a replacement may accept a wider input and return a narrower output - which is exactly contravariance in the parameter and covariance in the result.
Work the concrete case. Somewhere a value of type Cat => Animal is required. Is an Animal => Cat acceptable there? Hand it a Cat: it accepts any Animal, so yes. Take its result: a Cat, which is an Animal, so the caller's expectation holds. It substitutes cleanly, and the declaration Function1[-T1, +R] says so. Now try Animal => Any in the same slot: it accepts the argument fine, but the caller was promised an Animal and gets an Any, so it must be rejected - and it is, because Any is not a subtype of Animal. Once you have internalised that inputs go one way and outputs the other, the annotations on the collections stop looking arbitrary.
The get/put rule and variance-position checking
The rule that decides which annotation is legal is about position, not about mutability. If a type parameter appears only where values flow out of an instance - method results, the "get" direction - it may be covariant. If it appears only where values flow in - method parameters, the "put" direction - it may be contravariant. If it appears in both, it must be invariant. This is a soundness requirement that the compiler enforces mechanically, not a limitation it might relax later.
The check works by assigning each occurrence a position. Method result types are covariant positions; method parameter types are contravariant positions; and a type argument that lands in the contravariant slot of another constructor flips the position again, so the A in def each(f: A => Unit) is back in a covariant position after two flips. A var field is the case people get wrong: class Cell[+A](var value: A) is rejected not because the cell is mutable in some vague sense, but because var synthesizes a setter value_=(x: A) whose parameter is a contravariant position. The message names the accessor:
error: covariant type A occurs in contravariant position
in type A of value value_=That mutability is not the criterion is easiest to see from the other side. trait Eq[A] { def eqv(x: A, y: A): Boolean } is entirely immutable and still cannot be covariant, because A occurs only in parameter positions. It can, however, be contravariant - and that is the useful reading: a comparison written for Animal is usable anywhere a comparison for Cat is required.
Why a covariant mutable container is unsound. Suppose the compiler let the covariant cell through. The hole opens as soon as both aliases are in scope:
class Cell[+A](var value: A) // rejected - suppose it compiled
val cs: Cell[String] = new Cell("hello")
val ca: Cell[Any] = cs // legal if Cell is covariant
ca.value = 42 // an Int is an Any, so this type-checks
val s: String = cs.value // ClassCastException in the inserted castNothing in that sequence is individually suspicious; the corruption comes from the widened alias writing through to storage the narrow alias still reads. Java made exactly this choice for arrays, which are covariant, and pays for it with a store check on every reference-array write and an ArrayStoreException when the check fails. Scala's Array[T] is invariant for the same reason, which is why array code sometimes needs an explicit type argument that a List would have inferred.
The lower-bound escape hatch. Covariant collections still need to accept elements, and the standard trick is visible in the cons operator: def ::[B >: A](elem: B): List[B]. Two separate things make this work, and readers usually only ever get told the first. It is sound because nothing is written into the existing list - a new list is returned at a wider element type, so no alias observes a changed value. It type-checks because B is a fresh method-level type parameter, and class-level variance checking only constrains A; the sole occurrence of A is in the bound B >: A, which counts as a covariant position. The same shape appears in Option.getOrElse[B >: A], in +: on sequences, and in orElse. When your own covariant type needs a "put" method, reach for this before reaching for @uncheckedVariance, which silences the check without closing the hole.
Bounds: upper, lower, context, view
An upper bound A <: Closeable constrains a parameter from above and lets the body call the bound's members. A lower bound B >: A constrains from below and, as above, is the standard way to widen inside a covariant type. Both apply to abstract type members as well as to type parameters, so type Out <: AnyRef is a legal declaration. Only one upper bound is allowed, which is why two constraints are written as an intersection: A <: Closeable with Flushable in Scala 2, A <: Closeable & Flushable in Scala 3.
A context bound is different in kind. def sort[A: Ordering](xs: List[A]) does not constrain A by subtyping at all; it is sugar for an extra parameter of type Ordering[A] that the compiler supplies. It reads as "there exists evidence that A can be ordered", which is the type class idea rather than the inheritance idea, and it is the reason Scala can order types it does not own. The generated parameter is anonymous, so Scala 2 code retrieves it with implicitly and Scala 3 with summon; Scala 3 also added syntax for naming the context-bound parameter directly so you can skip the retrieval. How the compiler finds the evidence, and what happens when two candidates apply, is the subject of implicit resolution; the design patterns built on it are in implicits and type classes and given/using.
View bounds, written A <% B, meant "there is an implicit conversion from A to B in scope". They were deprecated in Scala 2 and removed in Scala 3, and the replacement is an ordinary context parameter of type A => B or Conversion[A, B]. The removal was not cosmetic: a view bound fired a conversion invisibly at every use of the parameter, so a signature that looked like a constraint was actually installing a silent rewrite of the argument.
Higher-kinded types: abstracting over the container
Types have kinds the way values have types. Int and List[Int] are proper types, kind *. List on its own is not a type at all - you cannot declare val xs: List - it is a type constructor of kind * -> *, a type with a hole. Map has two holes. A higher-kinded type parameter is a parameter whose argument is a constructor rather than a proper type, written with an underscore for each hole: def sizeOf[F[_]](fa: F[Int]): Int.
What this buys is the ability to write an interface once for every container of a given shape:
trait Functor[F[_]]:
def map[A, B](fa: F[A])(f: A => B): F[B]
given Functor[Option] with
def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa match
case Some(a) => Some(f(a))
case None => NoneWithout F[_] you can write a map for Option and a map for List but you cannot write a function that is generic over "things you can map". That capability is the entire foundation of Cats and ZIO's abstraction layers, and the architectural pattern built on top of it - parameterising an application over its effect type - is covered in tagless final. Compile-time generic derivation, which pushes the same idea to the shape of a case class, is in shapeless.
The practical friction is partial application. Either has two holes, so Either[String, *] - a constructor with the left side fixed - is not something Scala 2 can write directly; it needs the kind-projector compiler plugin's placeholder syntax, or the type-alias-in-a-refinement incantation ({ type L[X] = Either[String, X] })#L. Scala 3 has type lambdas in the language: [X] =>> Either[String, X]. Kind errors are a distinct class from type errors and read differently - passing List[Int] where F[_] is expected produces a complaint that a type constructor was required, not a type mismatch. Bounds work at higher kinds too: F[_] <: Iterable[_] is a legal constraint. More at higher-kinded types.
Type members and path-dependent types
Type parameters are not the only way to leave a type open. A trait can declare an abstract type member instead: trait Coll { type Elem; def head: Elem }. The two axes look interchangeable in small examples and diverge in real APIs. A type parameter is chosen by the caller and must be written, or at least inferred, at every use site. A type member is chosen by the implementation, can be refined progressively down a hierarchy, and does not inflate every signature that mentions the trait. The practical guidance: use a parameter when the type is an input the caller varies, and a member when the type is an output the implementation determines - a decoder's result type, a serialiser's intermediate representation.
The cost of a member is that it is invisible in the parameter list, so inference cannot constrain it where you need it. Libraries work around this with the Aux pattern, a type alias that lifts the member back into position: type Aux[A, B] = Conv[A] { type Out = B }. The braces there are a structural refinement - a type expression that narrows a nominal type in place rather than declaring a new one.
Type members also give Scala something Java has no analogue for. Because a member is selected through a value, its identity is tied to that value:
class Graph:
class Node
def connect(a: Node, b: Node): Unit = ()
val g1 = new Graph
val g2 = new Graph
g1.connect(new g1.Node, new g1.Node) // ok
g1.connect(new g1.Node, new g2.Node) // does not compileg1.Node and g2.Node are different types, so mixing nodes across graphs is a compile error rather than the runtime check and exception the Java version would need. That is a path-dependent type, and it is the mechanism underneath a great deal of type-level programming. One restriction worth knowing: Scala 3 dropped general T#A projection on abstract type members, which was unsound; selection on a stable path such as g1.Node is the supported form.
Self-types and structural types
A self-type declares a dependency without declaring inheritance. Writing trait UserService { self: UserRepo => ... } lets the body call UserRepo's members and obliges any concrete class mixing in UserService to also mix in UserRepo. Two differences from extends UserRepo matter. First, UserService does not become a subtype of UserRepo, so it cannot be passed where a repository is wanted - the dependency stays an implementation detail. Second, two traits can each declare a self-type on the other, which inheritance cannot express. The same syntax also names the enclosing this, which is how a nested class refers to the outer instance unambiguously. See self-types.
A structural type describes a shape rather than a name: def shut(x: { def close(): Unit }) = x.close() accepts anything with a matching method, whether or not it implements a common interface. The convenience is real and so is the bill. In Scala 2 a structural call is dispatched through java.lang.reflect with a cached method lookup, which is far more expensive than a virtual call and is gated behind import scala.language.reflectiveCalls so that nobody pays it by accident. Scala 3 routes structural selection through the Selectable trait; the default instance still reflects, but because the dispatch is now an ordinary method you can implement Selectable yourself and back a structural type with a Map. That is what makes typed access to genuinely dynamic records - a parsed JSON document, a JDBC row - possible without reflection at all. See structural types.
Scala 3: union, intersection, match and literal types
A | B is an untagged union. Unlike Either[A, B] there is no wrapper and no tag; a value of type Int | String is just an Int or just a String, and you narrow it with a match. It also gives you a way out of least-upper-bound widening: a heterogeneous collection that would previously infer List[Any] can be declared List[Int | String] and keep its precision. The same machinery underwrites explicit nulls, where under the relevant compiler flag a nullable string is spelled String | Null and the compiler forces you to handle the null branch.
A & B is intersection, replacing with in type position. It is commutative - A & B and B & A denote the same type - which is a genuine change from Scala 2, where A with B inherited the asymmetry of linearization. A member declared in both sides has the intersection of its two types.
Match types lift pattern matching to the type level:
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
summon[Elem[String] =:= Char] // compilesReduction happens during type checking, and when the scrutinee is still abstract the compiler leaves the type unreduced - which is the origin of the "match type does not reduce" errors that appear when you use one inside generic code without enough evidence. See match types.
Literal types make a single value into a type: val answer: 42 = 42 is legal, and x.type is the singleton type of a stable value. Combined with inline and match types they support real compile-time computation without writing a macro. Finally, opaque types give a distinct nominal type over an existing representation with no wrapper object and no allocation - inside its companion it behaves as an alias, outside it is abstract. That is a large enough subject to have its own treatment in opaque types, and this article defers to it.
Reading variance and inference error messages
Four message shapes account for most of the pain, and each has a specific diagnosis.
| Message shape | What it actually means |
|---|---|
| covariant type A occurs in contravariant position | A member's signature contradicts the annotation you wrote. Find the accessor named in the message - usually a setter or a parameter - and either widen it with a fresh lower-bounded parameter or drop the annotation. |
| found Box[String], required Box[Any] | The element types are compatible and the container is invariant. If you own Box, check whether the parameter occurs in any input position; if it does not, it can be made covariant. |
| inferred type arguments [Nothing] do not conform | Inference bottomed out because no argument constrained the parameter. Nothing anywhere in an error is a signal that inference gave up, not that you wrote Nothing. Pass the type argument explicitly. |
| no type parameters exist so that it can be applied | The constraint set had no solution, typically because two arguments push the same parameter in incompatible directions. Ascribe one of them and the real conflict surfaces. |
The general technique is to make the compiler show its work earlier. Adding explicit type arguments to a chained call bisects the failure to one link. Annotating the type you expect on an intermediate val moves the error to where you wrote the mistake instead of where the value is eventually consumed, which on a long chain can be a different file. Scala 3's -explain expands a mismatch into the subtyping steps that failed, and -Xprint:typer prints the tree after type checking with inferred type arguments and inserted contextual parameters made visible - the fastest way to find out that the instance being picked up is not the one you meant.
Where inference deliberately stops
Scala uses local type inference: constraints propagate outward from expressions and left to right through parameter lists, with no whole-program unification. Method parameters are never inferred and a recursive method needs a declared result type - the ground rules are covered in the language overview and in type inference. What is worth adding here is that the boundary is a design constraint on APIs, not just a list of cliffs.
Parameter-list order is an API decision. foldLeft is curried - foldLeft(z)(op) - because the first list fixes the accumulator's type before the second list is type-checked. That is why xs.foldLeft(List.empty[String])((acc, s) => s :: acc) needs no annotation on acc, while a single-list two-argument version would leave the lambda's parameters unknown at exactly the moment the compiler needs them. When you write your own combinator, put the shape-determining argument in an earlier list.
Lambda parameter types come from the expected type, never from the body. val f = x => x + 1 is rejected because there is no expected type to supply x; val f: Int => Int = x => x + 1 works because the ascription supplies it, and xs.map(x => x + 1) works because map's signature does. This is also why moving a lambda into a val to "clean things up" sometimes breaks compilation that was fine inline.
Methods are not values. A def has a method type, which is not a first-class type; converting one to a function value is eta-expansion. Scala 3 performs it automatically wherever a function type is expected, while Scala 2 required the explicit inc _ in most positions.
Scala 2 has no polymorphic lambdas. You cannot write a function value that is itself generic; the workaround is a trait with a polymorphic apply, which is why natural transformations were spelled F ~> G as a class rather than a function. Scala 3 adds polymorphic function types: val rev: [A] => List[A] => List[A] = [A] => (xs: List[A]) => xs.reverse.
One structural point ties these together: inference, overload resolution and contextual-parameter search do not run in separate phases. They interleave, each feeding the others partially-solved types. That is why a single missing given can produce an error about an unrelated type argument several lines away, and why the first fix to try is usually to supply a type the compiler was still guessing at.
Choosing an axis
Because the type system offers several tools that overlap, the useful skill is picking the smallest one that expresses the constraint.
| What you want | Reach for |
|---|---|
| A distinct type over an existing representation, no allocation | opaque type |
| Behaviour for a type you do not own | type class with a context bound |
| A type the caller varies at each use site | type parameter |
| A type the implementation determines and exposes | abstract type member, plus an Aux alias |
| An invariant tied to one specific instance | path-dependent type |
| A requirement on whatever a trait is eventually mixed into | self-type |
| Abstraction over the container itself | higher-kinded parameter |
| Substitutability between instantiations of your own type | variance annotation, after checking positions |
The reflex to reach for the most expressive available tool is the main way Scala codebases become unmaintainable, because every type-level device is compile time spent and an error message somebody will eventually have to read at 3am. A reasonable default order: plain algebraic data types and subtyping first; generics where duplication actually exists; variance only when callers genuinely need the substitution; type classes when behaviour must attach to types you do not control; and higher-kinded parameters, match types and type-level computation treated as library-author tools rather than application-code idioms. The expressiveness is there when a problem needs it, and the discipline is not using it when a problem does not.
Scala's types form a lattice with Any at the top and Nothing genuinely at the bottom, and most of the system's power is the ability to state precisely which substitutions are legal. Variance is the sharp edge and the most mis-stated part: an annotation is a claim about where the parameter occurs, so a type may be covariant when values only flow out, contravariant when they only flow in, and must be invariant the moment it does both - which is why a var field, rather than mutability in the abstract, is what makes a covariant container impossible. Bounds, higher-kinded parameters, type members and the Scala 3 union, intersection and match types are all further ways of narrowing the same lattice. Inference is local by design; learn the places it stops and annotate exactly there.