Union types

Union types would make a big difference in Kotlin. At the moment, a sealed class is the closest thing to a union type, but it has several limitations, the biggest being that you can only have subtypes defined by you as part of a sealed class.

The proposal would be for the type system to understand union types as a concept. A few examples.

Dynamic casting:

val something: Int|String|Boolean = TODO("implement")
if (something is String|Boolean) {
    // here it's a string or a boolean
} else {
  // here it's an int
}

Intersection of available functions:

class First(val value: Int) {
      
     fun speak() { println("shouting") }
}

class Second(val value: Int, val name: String) {
   
     fun speak() { println("talking") }
}

fun main() {

     val something: First|Second = TODO("implement")
     val value = something.value
     something.speak()
     // something.name doesn't compile
}

Syntax sugar for Null type:

val messageOrNull: String? = TODO("implement")
val messageOrNull2: String|Null = TODO("implement")

Covariance and contravariance:

fun main() {

     val consumerWider: (String|Int) -> Unit = TODO("implement")
     val consumerNarrower: (Int) -> Unit = consumerWider

     val producerNarrower: () -> Int = TODO("implement")
     val producerWider: () -> String|Int = producerNarrower
}

Covariant and contravariant overriding:

interface MessageProcessor {
    
     val format: String|Format

     fun process(input: String|Message): String|Message
}

class InMemoryMessageProcessor(override val format: Format) : MessageProcessor {

     override fun process(input: String|Message?): Message
}
2 Likes

Intersection of available methods? I don’t see how that could work without structural typing (which I think is a bad idea, as discussed on other pages).

In the example above, while both First and Second have methods called speak(), they’re not the same method. (Unless both classes implement some interface defining speak() — but then you don’t need union types, as you can simply use that interface instead.)

Assuming two methods behave the same simply because they have the same name (and types) seems unwarranted, and potentially very dangerous. In an example like this where both classes are defined in the same project, you can mitigate the risk — but AIUI the main benefit of union types is to unify types you don’t have control over (else you could use an interface).

But apart from intersection of methods, what can you do with an instance of a union type? Apart from stuff common to every type (equality checks, string conversion, and storage), you’re reduced to type checks and smart casts just about everywhere you use them. I’m failing to see much benefit.

1 Like

Benefits :
1 - On java, properties are defined as Map<String, Object>, with union type, you could have properties as :

class Tree<T>() {
    val value = HashMap<String, T|Tree<T>>()

    fun add(id : String, value : T|Tree<T>) {
        value.put(id, value)
    }

    fun get(id : String) : T|Tree<T>|Null {
        return value.get(id)
    }
}

class Properties : Tree<String|Number|Date>

Then, this is a hierarchical properties (can also be for json …)

2 - Iterator, with only one method

Interface Iterator<T> {
    fun next() : T|End
}
2 Likes

Why? instanceof checks are generated at compile time, e.g. instance is T[] or instance is String. Is a generic cast not possible?

Further, I think we need to check for possibly equal type parameters, e.g. S,T may be the same type.
And also the order of subtyping have to be taken into account to correctly arrange the instance of checks.
So also +1 for this proposal from me, it reminds me on the proposed introduction of union types in C#.

Feature is also available on scala 3, typed script and abandoned ceylon language

3 Likes

I work with Kotlin daily and I use Result types everywhere, and every time I write one, I REALLY miss having unions to represent error types.

This’s what a typical interface of mine looks like (simplified):

interface SomeRepository {

    fun add(
            item: ItemToAdd
    ): Either<AddException, RetrievedItem>

    fun find(
            itemId: ItemId
    ): Either<FindException, RetrievedItem?>

    fun findAll(
            query: ItemsQuery
    ): Either<FindAllException, Map<SimpleRetrievedItem, Weight>>

    sealed class AddException {
        object NoInternetConnectionException : PublishException()
        object NoSignedInUserException : PublishException()
        data class UnknownException(val origin: Throwable) : PublishException()
    }

    sealed class FindException {
        object NoInternetConnectionException : FindException()
        object NoSignedInUserException : FindException()
        data class InternalException(val origin: Throwable) : FindException()
        data class UnknownException(val origin: Throwable) : FindException()
    }

    sealed class FindAllException {
        object NoInternetConnectionException : FindAllException()
        object NoSignedInUserException : FindAllException()
        data class InternalException(val origin: Throwable) : FindAllException()
        data class UnknownException(val origin: Throwable) : FindAllException()
    }
}

This pattern is pretty much everywhere in my code and it’s pretty tiresome, wouldn’t it be nice to be able to do something like this:

object NoInternetConnectionException
object NoSignedInUserException
data class InternalException(val origin: Throwable)
data class UnknownException(val origin: Throwable)

typealias AddException =
    NoInternetConnectionException | NoSignedInUserException | UnknownException

typealias FindException =
    NoInternetConnectionException | NoSignedInUserException | InternalException | UnknownException

typealias FindAllException =
    NoInternetConnectionException | NoSignedInUserException | InternalException | UnknownException

Not only that, but it also removes the necessary mapping when moving these “Exceptions” between layers as all the layers, now, are basically using the same class and not different versions of the same class.
For example mapping RemoteDataSource.AddException to Repository.AddException to AddInteractor.Exception … etc.

It would also remove the unnecassary jargon when creating these exceptions

SomeRepository.AddException.UnknownException(...)

Simply becomes:

UnknownException(...)

It’s not limited to error types either, this also comes from a project I’m working on (simplified):

data class ItemToPublish(
    val user: SimpleRetrievedUser,
    val name: Either<Name, FullName>?,
    ...
)

Either works here but it’s a hack, the Either type is right-biased, its left is typically reserved for Errors. A union type would be much more appropriate here.

4 Likes

I think the most kotlin like way is to do it like they wanna it in C# over mapping union types to objects.

That is, you simply map a union type of multiple classes to the least common super class but with compile time knowledge what is inside and what can be safely extracted out and assigned in. It would also support implicit subsumption of union types.

The only disadvantage I see so far is that overloading over multiple union types might not be possible because different union types have the same upper bound, i.e. the same least common superclass.
However, we have the same problem with generics too which can be alleviated with the JvmName annotation to map the overloads to different function names.

I could live with that restriction and these union types would be easier to implement by compiler designers as it doesn’t require to implement a new vtable design, just use that of classes.

1 Like

I can’t see why kotlin way would better match with a non JVM language like C# than JVM one like Scala3 or Ceylon.

If i understand C# discussion (which is still just an idea ?) and your comment, a union type as ‘String|Int’ will be transformed to “least common super class”, here mean ‘Any’ ?

So, you couldn’t have ?

fun f1(y : Int|Bool) { ... }

fun foo(x : String|Int|Bool) {
    if (!(x is String) {
        f1(x)
    }
}

If i understand C# discussion (which is still just an idea ?) and your comment, a union type as ‘String|Int’ will be transformed to “least common super class”, here mean ‘Any’ ?

Yes

So, you couldn’t have ?

No, you couldn’t have

fun foo(y : Int|Bool) { ... }

fun foo(x : String|Int|Bool) {...}
1 Like

And if the types in the union implemented the same interfaces (note the plural here), the compiler should let you do as per the following:

interface Flying {
    fun fly()
}

interface Screaming {
   fun scream()
}

class A : Flying { ... }

class B : Screaming { ... }

class C : Flying, Screaming { ... }

fun main {
   
   val any: A|B|C = TODO("here")
   if (any !is B) {
       any.fly()
   }
}
4 Likes

+1 to what @AhmedMourad said.
Now that we’re going away from exceptions for error handling towards result types I find myself wanting union types almost every day. sealed classes are great but have severe limitations due to type hierarchy.

Let’s say I have 10 API endpoints. Each has a different successful result and a different set of error results.
4 can return UserNotFound, 7 can return NotAllowed, 2 can return InvalidPassword, all can return InternalError, and so on.

So I either

  • create 10 sealed classes (one per endpoint) where each lists all possible outcomes of the endpoint. That means there are 10 InnternalError subclasses, 7 NotAllowed subclasses etc… That means I cannot perform a generic error handling over NotAllowed and erase the type from the result because they are distinct types. Or…
  • I wrap everything into a generic Result<Value> (contains Value or Error) where Error is a common base type. Even if that Error is a sealed type I’d have to handle every possible error for every API endpoint even if they can never happen.

Both approaches have significant drawbacks.

Checked exceptions solved that problem, even though they had other issues. Now in Kotlin we don’t have checked exceptions and go towards using exceptions only for programming errors. That means we’re left with no good alternative to model data where a function can return multiple different outcomes, some if which are shared with other the outcome of other functions.

That’s a major use case I have quite frequently.

Another use case is JavaScript interoperability with Kotlin/JS. That is notoriously difficult if you have to consume TypeScript definitions with plenty of unions.

Another use case is better interoperability with GraphQL which does support union types.


Regarding JVM representation I think the “common supertype” approach is more than sufficient. The logic is already there in the compiler.

val foo = if (true) 1 else "bar" // = common supertype `Any`
// actually of common supertype `Comparable<*> & java.io.Serializable`

val bar = if (true) listOf(1) else setOf(2.2) // = common supertype `Collection<Any>`
// actually of common supertype `Collection<Comparable<*> & Number>`

I also wouldn’t mind if the Java API to a Kotlin library becomes “less safe” that way. If I want Java-interop and safety I’d not use unions. If I’m 100% in the Kotlin universe I don’t have to care about that.


Regarding overload conflicts on JVM I think that’s a non-issue.

We already have cases today where there is a legit overload in Kotlin (e.g. overloading by nullability or overloading by more specific generic type arguments) but it’s conflicting in JVM. Those cases are easily solved with the @JvmName annotation. The only exception are constructors which don’t allow for renaming.


Adding and removing types from a union (similar to @sollecitom’s example) would be an important feature. Also something the compiler is already capable of. But the syntax would be tricky.

fun <T> handleB(value: T|B): T without B
   = …

Roughly like this.

Use case:

fun <Result> process(result: Result|Error, processor: (Result without Error) -> Unit) {
   if (result is Error)
      // handle error
   else
     processor(result)
}

Some syntax thoughts:

  • Any type that’s not A~A
  • A type that’s T or AT | A
  • A type that’s T and AT & A
  • A type that’s T but not AT & ~A (the without from above)

So if T = A | B | C then T & ~A = B | C.
Basically like binary operators, but for types.

4 Likes

What’s difference between

 fun <T> handleB(value: T|B): T without B

and

fun <T> handleB(value: T|B): T

Does first means that if T and B are interfaces, you can’t return a type that implements B ? How can you control it ?
Or, does it mean that if B inherits from T, you can’t return a B instance ??

I believe “without” might be useful only for removing types from intersection types:

A & B without B is the same as A

At runtime this can be A or any subtype of A. There is no constraint left for B. The runtime type can be B but does not need to.

1 Like

The handleB example wasn’t good as it lacks a default value. Maybe more like that:

inline fun <T> defaultOnError(value: T|Error, default: () -> T & ~Error): T & ~Error =
   if (value is Error) default() else value

The resulting value is guaranteed to never extend Error.

Or like this:

abstract class Error
object NetworkFailure: Error()
object NoSuchUser: Error()
object NotAuthenticated: Error()
class User

fun fetchUser(id: String): User|NetworkFailure|NoSuchUser|NotAuthenticated =
    TODO()

fun <Result> process(result: Result|Error, processor: (Result & ~Error) -> Unit) {
    if (result is Error)
        // handle error
    else
        processor(result)
}

fun main() {
    process(fetchUser("foo")) { user ->
        println(user)
    }
}

Yes. It’s easy to control as seen in the example above. Just like whens work with sealed types already:

sealed class Foo {
   class A: Foo()
   class B: Foo()
   class C: Foo()
}

fun handle(foo: Foo) =
   when (foo) {
      is A -> …
      // `foo` is smart-casted to something like `Foo & ~A` here already
   }

For removing something from an intersection we could simply rely on generics. There’s no need for a special type:

fun <T> removeB(value: T|B): T = …

The returned value is no longer required to extend B but it may do.
It’'s analogous to the following which already works:

fun <T> removeNull(value: T?): T = …

Both approaches (generics and T & ~…) will do. They’re just different. Either we remove the explicit B from T & B but the resulting T may still extend B. Or we guarantee that the resulting value is in no case B by stating T & ~B.

1 Like