Classes and types are different

Some language designs, most notably Java, conflate classes and types. They even go further by conflating subclassing and subtyping. This might be convenient but it causes a lot of pain.

Without a clear line for how they’re distinct it becomes hard to understand how either one really works. It makes certain language features and design tradeoffs opaque. For instance, it is key for understanding “type erasure”. It lies at the basis for why Scala’s TypeTags are different from Java’s ClassTags. It can also be an underlying thing that makes variance more difficult to understand.

Finally, certain design advice like the Liskov Substitution Principle actually apply to types and not classes. When using a language which conflates these ideas you still have to design your classes to respect LSP, but there’s a subtle sleight of hand occurring.

Classes construct, types classify

The basic heuristic I’d use to remember how the two differ is classes construct, types classify. Classes are primarily constructor functions which generate new objects according to a certain specification. These new objects are classified according to a type with a related name.

Another useful mental tool is to remember that types exist only at compile time and classes operate at runtime. We’ll see this gets even more subtle and confusing later when taking a look at ClassTags and TypeTags, but the principle always holds.

A quick example

One of the best examples of the distinction is a class with a generic type parameter. For instance, an implementation of a pairing of compatibly typed values

class Both[A](fst: A, snd: A) {
  val first: A = fst
  val second: A = snd
}

val value: Both[Int] = new Both(2, 3)
val klass = value.getClass

On the last two lines we see the runtime construction of a new value which takes the type Both[Int]. Then we access its class using getClass which returns us a new runtime value, the actual class of Both itself.

Again, the type exists only at compile time while the class is a runtime thing. Additionally, with the introduction of the generic type parameter it’s important to note that the type of value is actually an expression in the type language which combines the types Both[_] and Int.

And so to be completely clear, class declarations generate two different, but related, things with similar or even identical names: a runtime class and a compile time type.

Subclassing versus subtyping

Another example of the conflation between classes and types has to do with the notions of subclassing and subtyping. Just as types and classes differ, these relationships aren’t the same. Let’s explore another example and really drive home how the different parts appear, interact, and differ. Again, type parameters are a good trick to drive out differences.

class Vehicle {}
class Car extends Vehicle {}

val vehicle = new Vehicle()
val car = new Car()

It’s easy to check that class Car is a subclass of class Vehicle. The reverse of this relationship is called isAssignableFrom:

> vehicle.getClass.isAssignableFrom(car.getClass)
true

It’s also the case that the type Car is a subtype of the type Vehicle. We can see that by checking that an expression like the following passes the type checker

val specificVehicle: Vehicle = new Car()

We know at runtime that specificVehicle.getClass is class Car, but at compile time all we know is that specificVehicle is of type Vehicle.

To really push the distinction a bit further, we can introduce a container type with an invariant type parameter

class Container[A](value: A)

Since we don’t explicitly specify the variance of the type parameter in this definition, it is the case that Container[Car] is not a subtype or a supertype of Container[Vehicle]. In other words, these two types, despite being superficially similar looking, are not related to one another at all! To prove it, let’s make some new definitions

val vehicles = new Container(vehicle)
val cars = new Container(car)

And we can check that neither of the following lines will pass the type checker

val wrong1: Container[Vehicle] = cars
val wrong2: Container[Car] = vehicle

Finally, we get to the pinch. Despite showing that the types of vehicles and cars are totally incompatible, their classes still have a subclass relation. In fact, they’re identical: all of the following are true

vehicles.getClass.isAssignableFrom(cars.getClass)
cars.getClass.isAssignableFrom(vehicles.getClass)
vehicles.getClass == cars.getClass

What have we learned?

A skeptic might at this point suggest that what this really proves is that classes ignore type parameters. And of course, this makes sense as it has been repeatedly driven home that JVM languages have the idea of type erasure.

Type erasure can be very confusing, however. It often appears that not all type information has been erased. Checks like instanceOf or pattern matches like

x match {
  case _: Int => true
  case _ => false
}

work and seem to exploit type information.

Hopefully now it’s more clear that most of these runtime “generic value inspection” technologies are actually using classes, not types. These techniques go wrong when you forget that classes are a coarser, less informative notion than types. Thus we can understand the intuitive idea that type erasure erases some information, but not all.

Going further

At the beginning of this article I hinted at this murkiness making trouble for use of the Liskov Substitution Principle. The LSP is a cornerstone of good OO class design, but is often misunderstood. It’s actually a statement about types and the meaning of the subtyping relationship. It implores you to only use subclassing when the subtyping relationship “makes sense”.

Additionally, knowing this distinction will help to pick apart the differences in Scala between ClassTag and TypeTag. The former is runtime information about the class which created an object. The latter is a special kind of type reflection which allows you to recover all of the information that type erasure would normally erase. In particular, it uses implicits to generate runtime representations of the types which used to exist back at compile time.

Finally, while I’ve used Java and Scala as running examples in this article, many other languages draw different lines between these concepts. For instance, there are many dynamically typed OO languages! In a language like Python there are no meaningful (compile time) types but class reflection is used regularly. Additionally, languages like OCaml have an object system where classes and types are more clearly distinguished. OCaml’s object system uses this distinction to its advantage by letting types and classes capture different kinds of designs.