Value Classes in Kotlin: Good-Bye, Type Aliases!?
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:
- They convey meaning through their name and make it easier for us to understand what kind of object is passed along.
- 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):
- the abbreviation of long signatures of generic types:
typealias Restaurant = Organization<(Currency, Coupon?) -> Sustenance
- 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 doc page
- and the corresponding KEEP.
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.
As a closing note: if your company needs a developer team to implement an iOS or Android app, reach out to us at contact@quickbirdstudios.com.
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.