# Type Checking

In Kotlin you can easily act on an object based on its type. Normally this activity is the domain of polymorphism, so type checking enables interesting design choices.

Traditionally, type checking is used for special cases. For example, the majority of insects can fly, but there are a tiny number that cannot. It doesn’t make sense to burden the Insect interface with the few insects that are unable to fly, so in basic() we use type checking to pick those out:

// TypeChecking/Insects.kt
package typechecking
import atomictest.eq

interface Insect {
  fun walk() = "$name: walk"
  fun fly() = "$name: fly"
}

class HouseFly : Insect

class Flea : Insect {
  override fun fly() =
    throw Exception("Flea cannot fly")
  fun crawl() = "Flea: crawl"
}

fun Insect.basic() =
  walk() + " " +
  if (this is Flea)
    crawl()
  else
    fly()

interface SwimmingInsect: Insect {
  fun swim() = "$name: swim"
}

interface WaterWalker: Insect {
  fun walkWater() =
    "$name: walk on water"
}

class WaterBeetle : SwimmingInsect
class WaterStrider : WaterWalker
class WhirligigBeetle :
  SwimmingInsect, WaterWalker

fun Insect.water() =
  when(this) {
    is SwimmingInsect -> swim()
    is WaterWalker -> walkWater()
    else -> "$name: drown"
  }

fun main() {
  val insects = listOf(
    HouseFly(), Flea(), WaterStrider(),
    WaterBeetle(), WhirligigBeetle()
  )
  insects.map { it.basic() } eq
    "[HouseFly: walk HouseFly: fly, " +
    "Flea: walk Flea: crawl, " +
    "WaterStrider: walk WaterStrider: fly, " +
    "WaterBeetle: walk WaterBeetle: fly, " +
    "WhirligigBeetle: walk " +
    "WhirligigBeetle: fly]"
  insects.map { it.water() } eq
    "[HouseFly: drown, Flea: drown, " +
    "WaterStrider: walk on water, " +
    "WaterBeetle: swim, " +
    "WhirligigBeetle: swim]"
}

There are also a very small number of insects that can walk on water or swim underwater. Again, it doesn’t make sense to put those special-case behaviors in the base class to support such a small fraction of types. Instead, Insect.water() contains a when expression that selects those subtypes for special behavior and assumes standard behavior for everything else.

Selecting a few isolated types for special treatment is the typical use case for type checking. Notice that adding new types to the system doesn’t impact the existing code (unless a new type also requires special treatment).

To simplify the code, name produces the type of the object pointed to by the this under question:

// TypeChecking/AnyName.kt
package typechecking

val Any.name
  get() = this::class.simpleName

name takes an Any and gets the associated class reference using ::class, then produces the simpleName of that class.

Now consider a variation of the “shape” example:

// TypeChecking/TypeCheck1.kt
package typechecking
import atomictest.eq

interface Shape {
  fun draw(): String
}

class Circle : Shape {
  override fun draw() = "Circle: Draw"
}

class Square : Shape {
  override fun draw() = "Square: Draw"
  fun rotate() = "Square: Rotate"
}

fun turn(s: Shape) = when(s) {
  is Square -> s.rotate()
  else -> ""
}

fun main() {
  val shapes = listOf(Circle(), Square())
  shapes.map { it.draw() } eq
    "[Circle: Draw, Square: Draw]"
  shapes.map { turn(it) } eq
    "[, Square: Rotate]"
}

There are several reasons why you might add rotate() to Square instead of Shape:

  • The Shape interface is out of your control, so you cannot modify it.
  • Rotating Square seems like a special case that shouldn’t burden and/or complicate the Shape interface.
  • You’re just trying to quickly solve a problem by adding Square and you don’t want to take the trouble of putting rotate() in Shape and implementing it in all the subtypes.

There are certainly situations when this solution doesn’t negatively impact your design, and Kotlin’s when produces clean and straightforward code.

If, however, you must evolve your system by adding more types, it begins to get messy:

// TypeChecking/TypeCheck2.kt
package typechecking
import atomictest.eq

class Triangle : Shape {
  override fun draw() = "Triangle: Draw"
  fun rotate() = "Triangle: Rotate"
}

fun turn2(s: Shape) = when(s) {
  is Square -> s.rotate()
  is Triangle -> s.rotate()
  else -> ""
}

fun main() {
  val shapes =
    listOf(Circle(), Square(), Triangle())
  shapes.map { it.draw() } eq
    "[Circle: Draw, Square: Draw, " +
    "Triangle: Draw]"
  shapes.map { turn(it) } eq
    "[, Square: Rotate, ]"
  shapes.map { turn2(it) } eq
    "[, Square: Rotate, Triangle: Rotate]"
}

The polymorphic call in shapes.map { it.draw() } adapts to the new Triangle class without any changes or errors. Also, Kotlin disallows Triangle unless it implements draw().

The original turn() doesn’t break when we add Triangle, but it also doesn’t produce the result we want. turn() must become turn2() to generate the desired behavior.

Suppose your system begins to accumulate more functions like turn(). The Shape logic is now distributed across all these functions, rather than being centralized within the Shape hierarchy. If you add more new types of Shape, you must search for every function containing a when that switches on a Shape type, and modify it to include the new case. If you miss any of these functions, the compiler won’t catch it.

turn() and turn2() exhibit what is often called type-check coding, which means testing for every type in your system. (If you are only looking for one or a few special types it is not usually considered type-check coding).

In traditional object-oriented languages, type-check coding is usually considered an antipattern because it invites the creation of one or more pieces of code that must be vigilantly maintained and updated whenever you add or change types in your system. Polymorphism, on the other hand, encapsulates those changes into the types that you add or modify, and those changes are then transparently propagated through your system.

Note that the problem only occurs when the system needs to evolve by adding more Shape types. If that’s not how your system evolves, you won’t encounter the issue. If it is a problem it doesn’t usually happen suddenly, but becomes steadily more difficult as your system evolves.

We shall see that Kotlin significantly mitigates this problem through the use of sealed classes. The solution isn’t perfect, but type checking becomes a much more reasonable design choice.

# Type Checking in Auxiliary Functions

The essence of a BeverageContainer is that it holds and delivers beverages. It seems to make sense to treat recycling as an auxiliary function:

// TypeChecking/BeverageContainer.kt
package typechecking
import atomictest.eq

interface BeverageContainer {
  fun open(): String
  fun pour(): String
}

class Can : BeverageContainer {
  override fun open() = "Pop Top"
  override fun pour() = "Can: Pour"
}

open class Bottle : BeverageContainer {
  override fun open() = "Remove Cap"
  override fun pour() = "Bottle: Pour"
}

class GlassBottle : Bottle()
class PlasticBottle : Bottle()

fun BeverageContainer.recycle() =
  when(this) {
    is Can -> "Recycle Can"
    is GlassBottle -> "Recycle Glass"
    else -> "Landfill"
  }

fun main() {
  val refrigerator = listOf(
    Can(), GlassBottle(), PlasticBottle()
  )
  refrigerator.map { it.open() } eq
    "[Pop Top, Remove Cap, Remove Cap]"
  refrigerator.map { it.recycle() } eq
    "[Recycle Can, Recycle Glass, " +
    "Landfill]"
}

By defining recycle() as an auxiliary function it captures the different recycling behaviors in a single place, rather than having them distributed throughout the BeverageContainer hierarchy by making recycle() a member function.

Acting on types with when is clean and straightforward, but the design is still problematic. When you add a new type, recycle() quietly uses the else clause. Because of this, necessary changes to type-checking functions like recycle() might be missed. What we’d like is for the compiler to tell us that we’ve forgotten a type check, just as it does when we implement an interface or inherit an abstract class and it tells us we’ve forgotten to override a function.

sealed classes provide a significant improvement here. Making Shape a sealed class means that the when in turn() (after removing the else) requires that each type be checked. Interfaces cannot be sealed so we must rewrite Shape into a class:

// TypeChecking/TypeCheck3.kt
package typechecking3
import atomictest.eq
import typechecking.name

sealed class Shape {
  fun draw() = "$name: Draw"
}

class Circle : Shape()

class Square : Shape() {
  fun rotate() = "Square: Rotate"
}

class Triangle : Shape() {
  fun rotate() = "Triangle: Rotate"
}

fun turn(s: Shape) = when(s) {
  is Circle -> ""
  is Square -> s.rotate()
  is Triangle -> s.rotate()
}

fun main() {
  val shapes = listOf(Circle(), Square())
  shapes.map { it.draw() } eq
    "[Circle: Draw, Square: Draw]"
  shapes.map { turn(it) } eq
    "[, Square: Rotate]"
}

If we add a new Shape, the compiler tells us to add a new type-check path in turn().

But let’s look at what happens when we try to apply sealed to the BeverageContainer problem. In the process, we create additional Can and Bottle subtypes:

// TypeChecking/BeverageContainer2.kt
package typechecking2
import atomictest.eq

sealed class BeverageContainer {
  abstract fun open(): String
  abstract fun pour(): String
}

sealed class Can : BeverageContainer() {
  override fun open() = "Pop Top"
  override fun pour() = "Can: Pour"
}

class SteelCan : Can()
class AluminumCan : Can()

sealed class Bottle : BeverageContainer() {
  override fun open() = "Remove Cap"
  override fun pour() = "Bottle: Pour"
}

class GlassBottle : Bottle()
sealed class PlasticBottle : Bottle()
class PETBottle : PlasticBottle()
class HDPEBottle : PlasticBottle()

fun BeverageContainer.recycle() =
  when(this) {
    is Can -> "Recycle Can"
    is Bottle -> "Recycle Bottle"
  }

fun BeverageContainer.recycle2() =
  when(this) {
    is Can -> when(this) {
      is SteelCan -> "Recycle Steel"
      is AluminumCan -> "Recycle Aluminum"
    }
    is Bottle -> when(this) {
      is GlassBottle -> "Recycle Glass"
      is PlasticBottle -> when(this) {
        is PETBottle -> "Recycle PET"
        is HDPEBottle -> "Recycle HDPE"
      }
    }
  }

fun main() {
  val refrigerator = listOf(
    SteelCan(), AluminumCan(),
    GlassBottle(),
    PETBottle(), HDPEBottle()
  )
  refrigerator.map { it.open() } eq
    "[Pop Top, Pop Top, Remove Cap, " +
    "Remove Cap, Remove Cap]"
  refrigerator.map { it.recycle() } eq
    "[Recycle Can, Recycle Can, " +
    "Recycle Bottle, Recycle Bottle, " +
    "Recycle Bottle]"
  refrigerator.map { it.recycle2() } eq
    "[Recycle Steel, Recycle Aluminum, " +
    "Recycle Glass, " +
    "Recycle PET, Recycle HDPE]"
}

Note that the intermediate classes Can and Bottle must also be sealed for this approach to work.

As long as the classes are direct subclasses of BeverageContainer, the compiler guarantees that the when in recycle() is exhaustive. But subclasses like GlassBottle and AluminumCan are not checked. To solve the problem we must explicitly include the nested when expressions seen in recycle2(), at which point the compiler does require exhaustive type checks (try commenting one of the specific Can or Bottle types to verify this).

To create a robust type-checking solution you must rigorously use sealed at each intermediate level of the class hierarchy, while ensuring that each level of subclasses has a corresponding nested when. In this case, if you add a new subtype of Can or Bottle the compiler ensures that recycle2() tests for each subtype.

Although not as clean as polymorphism, this is a significant improvement over prior object-oriented languages, and allows you to choose whether to write a polymorphic member function or auxiliary function. Notice that this problem only occurs when you have multiple levels of inheritance.

For comparison, let’s rewrite BeverageContainer2.kt by bringing recycle() into BeverageContainer, which can again be an interface:

// TypeChecking/BeverageContainer3.kt
package typechecking3
import atomictest.eq
import typechecking.name

interface BeverageContainer {
  fun open(): String
  fun pour() = "$name: Pour"
  fun recycle(): String
}

abstract class Can : BeverageContainer {
  override fun open() = "Pop Top"
}

class SteelCan : Can() {
  override fun recycle() = "Recycle Steel"
}

class AluminumCan : Can() {
  override fun recycle() = "Recycle Aluminum"
}

abstract class Bottle : BeverageContainer {
  override fun open() = "Remove Cap"
}

class GlassBottle : Bottle() {
  override fun recycle() = "Recycle Glass"
}

abstract class PlasticBottle : Bottle()

class PETBottle : PlasticBottle() {
  override fun recycle() = "Recycle PET"
}

class HDPEBottle : PlasticBottle() {
  override fun recycle() = "Recycle HDPE"
}

fun main() {
  val refrigerator = listOf(
    SteelCan(), AluminumCan(),
    GlassBottle(),
    PETBottle(), HDPEBottle()
  )
  refrigerator.map { it.open() } eq
    "[Pop Top, Pop Top, Remove Cap, " +
    "Remove Cap, Remove Cap]"
  refrigerator.map { it.recycle() } eq
    "[Recycle Steel, Recycle Aluminum, " +
    "Recycle Glass, " +
    "Recycle PET, Recycle HDPE]"
}

By making Can and Bottle abstract classes, we force their subclasses to override recycle() in the same way that the compiler forces each type to be checked inside recycle2() in BeverageContainer2.kt.

Now the behavior of recycle() is distributed among the classes, which might be fine—it’s a design decision. If you decide that recycling behavior changes often and you’d like to have it all in one place, then using the auxiliary type-checked recycle2() from BeverageContainer2.kt might be a better choice for your needs, and Kotlin’s features make that reasonable.

Exercises and solutions can be found at www.AtomicKotlin.com.