Value Classes in Kotlin: Good-Bye, Type Aliases!?

Cover Image: QuickBird in classroom

With the release of Kotlin 1.5.0, value classes (formerly known as inline classes) are finally stable and were released from their @OptIn annotations. Many were hyped about the release, but it also created a lot of confusion as we now have three very similar tools in Kotlin: Typealises, data classes, and value classes. So which one shall we use now? Can we drop our type aliases and data classes altogether and replace them all with value classes? That’s what we’re going to explore in this article.


The Problem

Classes in Kotlin solve two problems:

  1. They convey meaning through their name and make it easier for us to understand what kind of object is passed along.
  2. They enforce type-safety by making sure that an object of class A cannot be passed to a function that expects an object of class B as an input parameter. This prevents serious bugs at compile-time.

Primitive types like Int, Boolean or Double also enforce type-safety (you can’t just pass a Double where a Boolean is expected), but they don’t really convey a meaning (other than an object being a number of a certain format, for example).

A Double could be pretty much anything: a temperature in degrees Celsius, a weight in kilograms, or your screen’s brightness level in percent. All we know is that we’re dealing with a floating-point number with double precision (64 bits), but it doesn’t tell us what this number represents. For that reason, the semantic type-safety is lost:

If we have a function to set your display’s brightness level:

fun setDisplayBrightness(newDisplayBrightness: Double) { ... }

we can call this function with any Double value we want and might accidentally pass a number with a completely different meaning:

val weight: Double = 85.4
setDisplayBrightness(weight) // πŸ’₯

This programming error cannot be detected at compile-time and will most likely lead to a crash at runtime – or much worse: to unexpected behavior.


The Solution

There are different approaches to solving the two problems mentioned above. We could just wrap a primitive type in a class, but that comes with a lot of overhead. So let’s see how we can tackle these problems above with

  • data classes,
  • type aliases,
  • and value classes

and explore which of these tools is the best for this purpose!

1st Attempt: Data Classes

The easiest solution (present since the dawn of Kotlin) would be a data class:

data class DisplayBrightness(val value: Double)

fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }

βœ… Benefits

DisplayBrightness is thus a type of its own, that holds a Double value, but is assignment-incompatible with Double (i.e. setDisplayBrightness(DisplayBrightness(0.5)) would work while setDisplayBrightness(0.5) would cause a compiler error). While this would still allow the caller to do setDisplayBrightness(DisplayBrightness(person.weight)), it makes it much more obvious that there’s something fishy going on.

⛔️ Disadvantages

However, there’s one big disadvantage: Instantiating data classes is expensive. Primitive values can be written to the stack which is fast and efficient. Instances of data classes are written to the heap, which takes more time and memory.
How much more time, you ask? Let’s test it:

data class DisplayBrightnessDataClass(val value: Double)

@OptIn(ExperimentalTime::class)
fun main(){
    val dataClassTime = measureTime {
        repeat(1000000000) { DisplayBrightnessDataClass(0.5) }
    }
    println("Data classes took ${dataClassTime.toDouble(MILLISECONDS)} ms")

    val primitiveTime = measureTime {
        repeat(1000000000) { var brightness = 0.5 }
    }
    println("Primitive types took ${primitiveTime.toDouble(MILLISECONDS)} ms")
}

…leads to this output:

Data classes took 9.898582 ms
Primitive types took 2.812561 ms

While this performance hit might seem marginal at first, given that modern computers are really really fast, such small improvements end up making a big difference in performance-critical applications.

2nd Attempt: Type Aliases

Type aliases basically give a type a second name, like so:

typealias DisplayBrightness = Double

fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }

βœ… Benefits

Under the hood, Double and DisplayBrightness are synonyms.
Whenever the compiler sees DisplayBrightness, it basically replaces it with Double and moves on.
Since our new DisplayBrightness type alias is now equal to Double, it also receives the same optimization treatment as Double, resulting in equally fast speeds.
If we expand the test from earlier, we can see that the type alias and primitive type take approximately the same time to execute:

Data classes took 7.743406 ms
Primitive types took 2.77597 ms
Type aliases took 2.688276 ms

Since DisplayBrightness and Double become synonyms here, all operations that support Double, will also accept DisplayBrightness:

val first: DisplayBrightness = 0.5
val second: DisplayBrightness = 0.1
val summedBrightness = first + second // 0.6
first.isNaN() // false

⛔️ Disadvantages

The downside of this is that DisplayBrightness and Double are even assignment-compatible, meaning that the compiler will happily accept this:

typealias DisplayBrightness = Double
typealias Weight = Double

fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }

fun callingFunction() {
    val weight: Weight = 85.4
    setDisplayBrightness(weight)
}

So, did we really fix our problem? Well, only half of it. While type aliases make function signatures more explicit and are much faster than data classes, the fact that DisplayBrightness and Double are also assignment-compatible leaves the type-safety problem unsolved.

3rd Attempt: Value Classes

At first glance, value classes might look pretty similar to data classes. The signature looks exactly the same, except that instead of data class the keyword is value class:

@JvmInline
value class DisplayBrightness(val value: Double)

fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }

Also, you might have noticed the @JvmInline annotation. The KEEP about value classes explains this and the reason why value classes can currently only have 1 field.

ℹ️ Why @JmvInline is required:
In short, while the Kotlin/Native and Kotlin/JS backends would technically support value classes with more than one field, Kotlin/JVM currently does not. This is because the JVM only supports its built-in primitive types. There are however plans for the so-called project Valhalla (see the corresponding JEP), which would allow user-defined primitive types. As things are standing now, the Kotlin team deems project Valhalla to be the best compilation strategy for value classes. However, project Valhalla is far from being stable, so they needed to find a temporary compilation strategy that can be employed now. In order to make this obvious, @JvmInline is enforced for the time being.

βœ… Benefits

Behind the scenes, the compiler treats value classes like a type alias – with one major difference: Value classes are not assignment-compatible, meaning that this code will not compile:

@JvmInline
value class DisplayBrightness(val value: Double)

fun setDisplayBrightness(newDisplayBrightness: DisplayBrightness) { ... }

fun callingFunction() {
    val weight: Double = 85.4
    setDisplayBrightness(weight) // πŸ’₯
}

With the performance test from above extended, we can see that value classes have an equally fast performance as primitive types and thus type aliases:

Data classes took 7.268809 ms
Primitive types took 2.799518 ms
Type aliases took 2.627111 ms
Value classes took 2.883411 ms

Should I always use value classes?

So, it seems like value classes do check all the boxes, right? They…

  • make variable declarations and function signatures more explicit, βœ…
  • keep the efficiency of primitive types, βœ…
  • are not assignment-compatible with their underlying type, preventing the caller from doing stupid things, βœ…
  • and support many features of data classes, like constructors, init, functions and even additional properties (but only with getters). βœ…

The only remaining use-case for a data class is when you need to wrap multiple parameters. Value classes are limited to one parameter in their constructor for the time being.

Similarly, type aliases still have use cases that cannot be covered by value classes (or do not align with their purpose):

  1. the abbreviation of long signatures of generic types:
typealias Restaurant = Organization<(Currency, Coupon?) -> Sustenance
  1. parameters of higher-order functions:
typealias ListReducer<T> = (List<T>, List<T>) -> List<T>

Except for those exceptions, value classes really are the best solution for most cases. (For that reason, we are currently moving our projects to value classes.)


Going Further

There are two documents that really helped us understand how value classes work and what engineering thoughts went into the design process:

The KEEP also talks about possible future developments and design ideas. This article on typealias.com explains how type aliases work and how they should be used – a recommended read.

If you’re interested in Kotlin language engineering in general, you might like our blog article Kotlin’s Sealed Interfaces & The Hole in The Sealing. We always grow through your feedback, so if you have any, please get in touch with us on Twitter! Thanks for reading!

Are you an Android Developer?
Do you want to work with people that care about good software engineering?
Join our team in Munich

🐣

Get notified when our next article is born!

(no spam, just one app-development-related article per month)


Cover Image: QuickBird in classroom