The problem of union types for type systems
KotlinConf 2024 shocked me with a new announcement. Union types are coming to Kotlin! However, in a limited form. At first, I couldn't understand why, but after some talks and discussions, I realized that it is a very smart move. Now let me show you the big picture.
Union types
One feature I have always admired in other programming languages is union types, such as Int | String
. I haven’t had any specific plans for using it, but it seems to offer an additional level of expressiveness. I often wondered why Kotlin didn't include this feature. Now I understand why!
Let's start our story with an example of where union types shine. Imagine that a variable is either String
or Int
. Currently, the result is Any
, but it could instead be a more specific String | Int
that, after is-checking for one type, could be smart cast to another.
Union types could be passed around as a substitute for the current Either or Result classes.
As cool as it might look, now let's consider how much complexity it introduces to our type system. Consider the following example. What type should be the result of List<Cat>
and List<Dog>
? Should it be List<Animal>
, List<Dog | Cat>
or List<Cat> | List<Dog>
? The first option seems most intuitive, and the last one most precise.
That is just the beginning of troubles. Type Dog | Cat
would need to be a subtype of Animal
, as well as all other types common to all interfaces in the union.
This is especially problematic once we consider generic classes. The following code would not compile because the inferred type of animals would be MutableList<Cat | Dog>
. Currently, it would compile because it is MutableList<Animal>
.
The problem with generics is far deeper. It is actually proved that inference is NP-hard in the presence of union types. The following slide offers a hint why.
In general, subtyping with generics is not decidable. This means that there is no algorithm that can determine, in a finite amount of time, whether a given type-related question can be resolved. Union types make things even harder, especially for inference.
That seems like an absolute blocker for union types, but as researcher Ross Tate discovered, there is a way to have a cake and eat it too. As it turns out, there is one particular use case where we use union type substitutions, and that case does not cause any of the problems mentioned above. It is using union types to represent either a result or a failure.
Union types with errors
The Lead Kotlin Designer announced union types with errors. That will most likely mean special classes that specify throwable errors.
Union types are supertypes of both types, so String | Error
is a supertype of both String
and Error
, so it accepts both String
and Error
. Just like Int?
accepts both Int
and null
(Int?
is like Int | null
).
This feature should particularly help when a variable needs to represent either a value or a placeholder for a lack of value, like T | NotFound
. It is useful when null cannot represent a lack of value.
Union types with errors are also a replacement for types like Either
or Result
, just like nullable types were a replacement for Optional
.
What is the advantage of using union types with errors instead of Either
or Result
? As a built-in construct, it can be much more efficient and convenient.
Either
or Result
must be represented with an object, that stores a value. Union types do not need that, they allow raw value passing. That also means we do not need to pack values, we can just use them where needed.
Union types with errors also support a number of operators, similar to those supported for nullable types.
!.
call will call a function or a property only if value is not an error, soa!.b
translates toif(a is Error) a else a.b
!:
provides a default value in case of an error, soa !: b
translates toif(a is Error) b else a
!!
just throws the throwable error if this value is an error, or returns the other value otherwise. Soa!!
translates toif(a is Error) a.throw() else a
With such support, we will be able to conveniently transform values in functional style. Just like we do now with nullability, but errors additionally store information about what went wrong.
Union types will be introduced step-by-step, first inside Kotlin stdlib to optimize algorithms.
Discussion about name
I wondered, why "union types with errors", and not "exceptions", "throwables" or something else. I asked about it Ross Tate, and as it turns out, the name is not final. The Kotlin team is still discussing it. I was even asked to start a discussion about it. So here it is:
I personally think that "union types with failures" would be a better name. Here is an issue I made, if you agree with me, please upvote it:
Conclusion
Union types with errors are a very smart move. They offer a lot of expressiveness without introducing the complexity of full union types. They are a perfect fit for representing either a result or a failure.