Union types

I like the idea of using inline to remove the wrapper instance in most cases but I think I’m failing to understand some extra benefit of you proposal beyond just declaring sealed classes with less code.

How does “operator fun getValue()” come into play exactly? Your example does not appear to use property delegation.

I also am failing to see how this addresses some of the boiler plate:

  • Does “operator fun getValue()” make accessing the inner value easier somehow?
  • How would such a FlexibleDate be Serializable? Is that what parse and print are for?
  • Would a user have to manually write a method to convert a FlexibleDateInput (subset of types) to a FlexibleDate?
  • If a user wanted to to accept both Number and third.party.lib.Number, how would that work?
  • If a user has input of type ‘Any’ how do they convert it to FlexibleDate?

Some of these seem relatively straight forward to solve:

  • An attribute could sidestep name collisions or even just allow for clearer names.
  • A cast method that takes Any could be added to the definition.

The others I’m uncertain about.

Of course this also relies on inline classes being allowed to take part in a class hierarchy with no fields or init blocks.

I personally am not a huge fan of @nickallendev proposal.
Creating a new instance every time a function with a union is called is not a good thing for performance.

Here is my own proposal:

A. Definition of Union type
A union type can be defined as [TypeA | TypeB | TypeC], which allows (non exhaustive):

  • argument type definition: fun toJson(value: [String | Number])
  • receiver type definition: fun [String | Number].toJson()
  • return type definition: fun nextElement(): [String | Number]
  • variable / value type: val element: [String | Number]

And most importantly:

  • type alias definition: typealias Element = [String | Number]

B. Compilation
A union type is compiled as the first super common type, annotated with @KotlinUnion.
[String | Number] is compiled as @KotlinUnion(String::class, Number::class) Any
[String | StringBuilder | StringBuffer] is compiled as @KotlinUnion(String::class, StringBuilder::class, StringBuffer::class) CharSequence

C. Direct usage
Without being disambiguated, a union value can only be used as a union type that is equivalent or larger (e.g. a [String | Number] value can be used as a [String | Number | Boolean]).

D. Disambiguation
A union value can be disambiguated either via a when or a if statement:

fun toJson(value: [String | Number | Boolean]): String {
    return when (value) {
        is String -> "\"" + value.jsonEscaped() + "\""
        is Number -> value.toString()
        is Boolean -> if (value) "true" else "false"
    }
}

Notice the absence of else clause.

E. Generics parameters & wrapping
Union types cannot be used as generic parameter (e.g. List<[String | Number]> is forbidden). However, union types can be used in inline classes, making them usable as generic parameter.
Therefore, this is legal:

inline class Element(val element: [String | Number])
val list = ArrayList<Element>()

F. Generic components
Union types cannot contain generic components (e.g. fun <T> foo(v: [String | T]) is forbidden).
However, it is possible for a union type to contain an inline reified generic type.
Therefore, these are legal:

inline fun <reified T> foo(v: [String | T])
inline fun <reified T> Map<String, T>.remove(v: [String | T])

G. Java compatibility
In non-private methods & functions (e.g. java accessible), much like the non-null check intrinsic, a type check is added at the start of the function:

if (v !is String && v !is Number) throw IllegalArgumentException("$v does not conform to [String | Number]")

Note that the performance cost of instanceof is known to be very small.

It is a typo, a inspected some solutions like

when(val date by flexibleDate) {
  is String -> println("String $date")
...

But I cannot expressed it well, I not found an acceptable Kotlin code but I not abandoned this idea.
I mainly dislike some value field, ie flexibleDate.value, so I considered also flexibleDate[] using the get operator (it does not work without arguments, now).

Yes, it is the base idea, however a working delegation can provides some extra benefits.

This point is uncovered, yet.

A some form of type alias can solve this question.

Explicit cast is required: FlexibleDate.String( any as String), like every other type.

Agree with your proposal (+1 for the notation with [x |y]), except for E. & F., i can’t see why union as generic parameter should be forbidden.
I think Kotlin should take ideas on languages that already have union type : scala (in next version), typescript, ceylon

For example, union types comes with intersection type, [ X | [Y & Z] ] …

After thinking a bout it… neither do I (so, in essence, the E clause has no real reason of existence).

The F clause is however important IMO.
Because the compiler has to generate instanceof checks every time a union is used, it has to know the exact types the union is composed of at compile time. Which is why a Union type cannot be type erased.
Hence, a union type can only contain a generic type parameter if it is reified.

I think the Visitor pattern, along with some composition can be used to represent this in the JVM in a type-safe way.

an interface is generated for each union type:

interface UserScreenState
{
    fun <R> accept(Visitor<R> visitor):R

    interface Visitor<R>
    {
        fun visit(LoadingWrapper):R
        fun visit(DataWrapper<T>):R
        fun visit(ThrowableWrapper):R
    }
}

interface FriendsScreenState
{
    fun <R> accept(Visitor<R> visitor):R

    interface Visitor<R>
    {
        fun visit(LoadingWrapper):R
        fun visit(DataWrapper<T>):R
        fun visit(ThrowableWrapper):R
        fun visit(EmptyWrapper):R
    }
}

these generated wrappers are needed to add types from 3rd party libraries to the union.

class LoadingWrapper(val value:Loading) : UserScreenState, FriendsScreenState
{
    fun accept<R>(visitor:UserScreenState.Visitor):R = visitor.visit(this)
    fun accept<R>(visitor:FriendsScreenState.Visitor):R = visitor.visit(this)
}
class DataWrapper<T>(val value:Data<T>): UserScreenState, FriendsScreenState {...}
class ThrowableWrapper(val value:Throwable) : UserScreenState, FriendsScreenState {...}
class EmptyWrapper(val value:Empty) : FriendsScreenState {...}

and then, when we write a when statement in Kotlin, it can be transformed into an anonymous implementation of the corresponding Visitor interface. And for Java interop, Java users could write implementations of Visitor interfaces directly.

val html = friendsScreenState.accept(object:FriendsScreenState.Visitor<Unit>
{
    fun visit(e:LoadingWrapper) {...}
    fun visit(e:DataWrapper<T>) {...}
    fun visit(e:ThrowableWrapper) {...}
    fun visit(e:EmptyWrapper) {...}
})

Scala 3 (Dotty) has union types.
https://dotty.epfl.ch/docs/reference/new-types/union-types.html

I would love to see union types in Kotlin as well.

3 Likes

Hmm, I am confused that no one mentioned “control flow” when discussing about Unions. Haskell and Rust for example use Unions instead of exceptions. If you want to have a break(2) for a function in Java you have to throw a exception and catch that. That is very annoying and expensive. Python even uses exceptions to end an iteration over a list. With Unions it would easily be possible to return Union(ReturnType, Error). The ReturnType can only be accessed if the error was handled, too (or bubbled).
This could be translated into Java as a generic class Tuple with n fields (in my example 2) with fixed types which are nullable and final. In Java you have check against null manually or could use a function like getAOrThrowB (In the context of error handling) since java code really shines in NullPointerExceptions and throwing all kind of Exceptions even when there is none.
My example is limited on the Type/Implementation Either, but Either is in the end nothing else than a Union.

What has to be regarded when designing Unions: Optionals are a really bad implementation of Unions (since they can be null themself). Even Java Objects are Unions since they can point to an object or point to null (Object | Pointer).
Unions are already in Java but they are reserved for Object|Null and cannot be extended to other types or longer union chains. The only part which is missing is a generalization (and to add compatibility to java or add an annotation to not do that since it destroys the efficiency adding a new object wrapper to each return).

1 Like

RUST uses specific classe Result to treat errors without need of union types.
With Either (as tuple), the order of element is important, with union, Integer|Date|String is same as Date|String|Integer.

The languages i know that have union types are :
future scala3, typescript, and ceylon (dead language ?).

and yes, in kotlin, String? is String|Null , and becomes String inside block if (x != null) { }
with the same logic, String|Integer|Date becomes String|Integer inside if (!(x is Date)) {}

1 Like

Not having union types makes it hard to come from Flow or Typescript where unions are extensively used. There is no clear alternative with similar mental model. For example, how can I pass union type parameters to a function?

It’s not possible, you said it yourself.

There are a couple of alternative approaches. The best alternative approach depends on the particular case.

A very concise approach is using overloaded functions. For each of the types in the union create a separate overloaded function. These functions have the same name but different argument types. If the implementation of these functions has common logic, extract it to a private function that can be re-used by all of them. This approach might not work that well, depending on the nature of the common logic.

Another approach is what I call the typeclass approach. There is a proposal to add native support for typeclasses to Kotlin. It would reduce the boilerplate of using this approach. Without native support it has some boilerplate:

  1. For 2 independent types A and B create an interface I with 2 implementations IA(A) and IB(B).

  2. Instead of the union type use this interface as the type of the function argument.

  3. Give the interface all the members that you need in your function.

  4. For invocation convenience, create overloaded functions for A and B that delegate to the first function.