Inside DrMark’s Lab

Inside DrMark’s Lab

Understanding Scala 3’s TypeTest: Runtime Type Checking for Generic Parameters

How I Learned to Stop Trusting Type Erasure and Love the TypeTest

The Unshielded Mind's avatar
The Unshielded Mind
Nov 07, 2025
∙ Paid

Type erasure has been a fundamental limitation of the JVM since its inception. When you write generic code in Scala, the type parameters disappear at runtime, making it impossible to reliably check types in pattern matching. Scala 3 introduces TypeTest to address this challenge, providing a way to perform sound runtime type checks on generic parameters. You can find the program that uses TypeTest in my Github repo.

I’ll read the Java Language Specification to understand how type erasure works and why it’s fundamental to Java.Now I’ll write a section explaining type erasure based on the Java Language Specification.

Type Erasure in Java: A Design Decision for Compatibility

Type erasure is one of the most fundamental and controversial aspects of Java’s generic type system. Understanding why it exists requires examining the practical constraints Java faced when introducing generics in Java 5.

The Java Language Specification (JLS) defines type erasure as a mapping from types, possibly including parameterized types and type variables, to types that are never parameterized types or type variables. The erasure mapping follows specific rules. The erasure of a parameterized type like List<String> is simply List. The erasure of a nested type T.C is |T|.C, where |T| denotes the erasure of T. The erasure of an array type T[] is |T|[]. The erasure of a type variable is the erasure of its leftmost bound. For all other types, the erasure is the type itself.

This process applies not only to types but also to method and constructor signatures. When you write a generic method, its signature undergoes erasure, removing all type parameters and replacing parameterized types with their erasures. A method with signature <T> List<T> process(List<T> items) becomes simply List process(List items) after erasure.

The consequence of erasure is that certain type information becomes unavailable at runtime. The JLS distinguishes between reifiable and non-reifiable types. A type is reifiable if and only if it is completely available at runtime. Non-generic class types, parameterized types with unbounded wildcards, raw types, primitive types, and arrays of reifiable types are all reifiable. Generic type parameters like T in List<T> are not reifiable because the actual type information is erased.

The decision to implement type erasure was not arbitrary. It emerged from the need for migration compatibility. When Java introduced generics, millions of lines of existing code were already in production. The Java designers faced a choice between two approaches to platform compatibility. The first option was to leave existing functionality unchanged and introduce an entirely new library for generic collections. However, this approach had severe limitations. Collections are used to exchange data between independently developed modules. If one vendor decided to use the new generic library while another continued using the old library, they could not interoperate. Libraries dependent on vendor code could not be modified until suppliers updated their own code. When modules were mutually dependent, changes had to occur simultaneously across all codebases.

The JLS states clearly that platform compatibility, as outlined above, does not provide a realistic path for adoption of a pervasive new feature such as generics. Instead, the design of the generic type system seeks to support migration compatibility. This approach allows existing code to evolve and take advantage of generics without imposing dependencies between independently developed software modules.

The price of migration compatibility is explicitly acknowledged in the specification. A full and sound reification of the generic type system is not possible, at least while migration is taking place. This is why Java allows raw types, which are essentially the erased versions of generic types. Raw types exist to facilitate interfacing with non-generic legacy code. For example, you can use ArrayList as a raw type that interoperates with both old code that expects ArrayList and new code that uses ArrayList<String>. The specification strongly discourages using raw types in new code, noting that their use is allowed only as a concession to compatibility of legacy code.

The practical impact of this design choice manifests in everyday programming. When you write new ArrayList<String>(), the JVM actually creates an ArrayList object with no type parameter information. The String type exists only at compile time for static type checking. At runtime, the JVM cannot distinguish between an ArrayList<String> and an ArrayList<Integer> because both have been erased to simply ArrayList. This explains why you cannot perform certain operations with generics. You cannot write if (list instanceof List<String>) because the runtime has no knowledge of the String type parameter. You cannot create an array of a generic type like new T[10] because array creation requires reifiable type information.

This design represents a fundamental tradeoff in Java’s evolution. The language prioritized smooth migration and backwards compatibility over runtime type information. While this decision enabled the Java ecosystem to adopt generics without fragmenting into incompatible versions, it also imposed limitations that persist today. Every pattern matching operation on generic types, every attempt to preserve type information at runtime, must work around the constraints imposed by type erasure.

The Type Erasure Problem in Scala

Consider a simple function that attempts to catch a specific exception type. You might write something like the following example.

def catchIt[E](thunk: => Unit): Unit =
  try thunk
  catch case e: E => println(”catch it”)

This code compiles in Scala 3, but it comes with an unchecked warning. Due to type erasure, the type parameter E becomes essentially Object at runtime. The catch block will match every exception, regardless of what E actually represents. If you call this function with catchIt[IOException] and throw a NullPointerException, it will still print “catch it” because the runtime cannot distinguish between exception types when E is erased.

How TypeTest Works: Execution Traces

To understand TypeTest deeply, we need to trace through concrete examples and see exactly what happens at compile time and runtime. Let’s start with a simple scenario and progressively reveal the mechanism. Consider the following function that attempts to filter values by type.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2026 Markgrechanik@gmail.com · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture