Union types

#63

I like this example, specifically because it also covers structural typing. Agree that the Union should always express N is-able identities.

#64

Not for overload languages like kotlin, they don’t need to pattern match over the type off a union, just overload a function over all type parameter, then apply it to a value of Union type parametrized by these type parameters:

fun(a:A)=(a,)
fun(b:B)=(b,b)
fun(c:C)=(c,c,c)

x: A | B | C = new A()
fun(x) //valid, not pattern matching or type classes needed.

It would work in my eyes but it would break backward compatibility in source.

No, traits/typeclasses/concepts/protocols/interfaces denote unbounded existentials whereas union types are bounded.
You save only dispatching for the methods of a trait but not for foreign methods which is the default case when pattern matching over unions.
Then, you can’t even overload over all possible types of a typeclass because you may never know how many member it houses.
You can of course create new typeclasses to save pattern matching but it is far more tedious then simple overloading over the type parameters of a union type.

#65

That looks super wrong because with the current Kotlin version, an overload is resolved strictly at compile time. Doing this dynamically at runtime depending on the actual type looks very weird to me and is difficul to implement as there are lots of weird edge cases. Like what happens if an object implements both A and B.

#66

True and it shouldn’t be different for union types. What happens here is that the compiler tries to find fun with type A | B | C -> T but it doesn’t exists.
Then the compiler tries to find a signature of fun for each type parameter of the union type and succeeds, here.
But because it doesn’t know at compile time of which type x really is, it pattern matches for you automatically with:

if(x instanceOf A) { call fun:A->(A,)((A)x)}
else if (x instanceOf B  { call fun:B->(B,B)((B)x)}}
...

It is exactly that what you try to do manually, but the compiler can automate it.

Yes, union types are non disjoint if overlapping occurs, then an appropriate convention has to be defined. What do you do if fun is overloaded with Interface I1 and I2 and fun is applied to i implementing both interfaces?
Similar problems arrive with union types, we could choose for the first match or we could throw an type error.

But because we have already intersection types for functions (method overloading) and for interfaces both inherently causing ambiguities, why we shouldn’t also make use of union types.

#67

As such errors currently only happen at compile time, we just fail the compile and force the programmer to cast to a more specific type.

Doing this at runtime is much more problematic, as the type AB that implements both A and B could be dynamically created or loaded at runtime.

Sure, you could just translate this into chained instanceof calls and ignore ambiguous types, but I don’t feel that that would be the right approach.

#68

You can do then same for union types, if an object implements both interfaces, then you can emit an compiler error becaue of ambuigity.

You go for the worst case, if this can be true at runtime you emit an compiler error at compile time a.k.a path dependent typing

#69

No you can’t. Unless you really restrict union types to final/closed/sealed classes there is no way that you can prevent someone from implementing multiple interfaces or extending a class and implementing an interface. Say you have

fun getResult() : String|Throwable = TODO()

fun handleResult(foo: String) = TODO()
fun handleResult(foo: Throwable) = TODO()

handleResult(getResult())

That would work as String and Throwable are both classes and you can’t extent multiple classes. However

fun getResult() : List<String>|Throwable = TODO()

fun handleResult(foo: List<String>) = TODO()
fun handleResult(foo: Throwable) = TODO()

handleResult(getResult())

would not work, as someone could create an exception class that also is a List<String> (to iterate over error messages or whatever), and they could create it after you compiled your handleResult code. So you wouldn’t know there could be a possible conflict.

You go for the worst case, if this can be true at runtime you emit an compiler error at compile time a.k.a path dependent typing

that would make it almost useless, as in larger projects you usually code with interfaces a lot.

#70

On your example, you need to have a function that treat the declared result :

fun getResult() : List<String>|Throwable = TODO()
fun handleResult(foo: List<String>|Throwable) = TODO() 
handleResult(getResult())

until you check the type

fun getResult() : List<String>|Throwable = TODO()

fun handleResult(foo: List<String>) = TODO() 
fun handleResult(foo: Throwable) = TODO()    

val x = getResult()
if (x !is Throwable) {
    handleResult(x)  // call handleResult(foo: List<String>)
}

But the strength of union type adopt by typescript, ceylon and the next scala version, is for types (like examples show in precedent comments)

// Let be properties type be a map of key string and value (not simple object)
val propreties : Map<String, String|LocalDate|Number>

Others examples with json type.

#71

True, so maybe you don’t want to treat String | Throw as a class :), in the end it is still a class but it is not visible in the abstraction. In your case above, you can’t simply pass a union type into a non union type, they are different by design (and even don’t stay in a subtyping relationship), instead you copy out the reference typecased by type id and cast it to the appropriate target type.
This should be possible in the JVM, for illustration by:

UnionOfStringAndThrowable //generated by compiler
{
 value:Object;
 tag:TypeTag;
 getValue():T=(T)value; 
}

Now, every time you retrieve an value out of this union you pattern match over the current type tag, the order known by the compiler:

if(unionValue.tag == TypeTag.String)
{ 
     fun(unionValue.getValue():String);
}
else
{ 
     fun(unionValue.getValue():Throwable)
};

The point is that every type system needs to know some ‘actual’ type of an expression even if this expression satisfies more than one type assertion.
So, in the end your foo Object is either a T extends Throwable or a T implements List<String>, but not both at the same time.
We could argue that u:Int|Float=2 is of type Int|Float and we don’t know how to destruct it correctly as Int or Float, respectively but 2 is per default an integer and our union type has a type tag to remember for.
Regarding the abstraction, you are right, but because we have one single type tag in the implementation we can reconstruct the initial type choices we made.

The situation changes considerably for concrete types like Int, they aren’t shipped with type tags and it wouldn’t be tractable further to do so by means of a combinatoric explosion.
So, if we allow Int to be regarded as NegativeInt | ZeroInt | PositiveInt we run into troubles and we need to throw an compile time error or another convention.

#72

FWIW, if we could implement interfaces by extensions (Swift-isms ahead), union types could be made explicit:

[sealed] interface StringOrThrowable { ... }
extension String : StringOrThrowable { ... }
extension Throwable : StringOrThrowable { ... }

We would need sealed interfaces for full equivalence, I guess.

1 Like
#73

With real union type, you can have

fun foo(x : String|Number) = TODO()

fun foo1(y : String|Number|LocalDate) : Unit {
    if (y !is LocalDate) {  // so y is String|Number
        foo(y);
    }
}

Not the case with function defined as

fun foo(x : StringOrNumber) = TODO()

fun foo1(y : StringOrNumberOrLocalDate) : Unit { ... }

since StringOrNumber is not related with StringOrNumberOrLocalDate

1 Like
Does Kotlin have multi-catch?
#74

I think what I had in mind with “sealed interfaces” would solve this particular case, at least: if the compiler knows all types implementing StrongOrNumberOrLocalDate, it can infer y to be String or Number in your example.

That said, it has no way to get to StringOrLocal (in general), and that interface might not even exist. As such, union types are definitely be the neater model here, agreed.

#75

Thought I’d take a stab at a Kotlin union design for fun.

Basically the idea is to use syntactic sugar for the already existing sealed classes but also extend the benefits of sealed classes to the type parameters of sealed classes if that type parameter is specified for all instantiable subclasses.

The definition could be something like:

union class FlexibleDate { String, Number, LocalDate }

This is compiled to the equivalent of:

@SealedUnion
sealed class FlexibleDate<T> {
    abstract val value: T
    companion object {
        @JvmStatic()
        @JvmName("from")
        operator fun invoke(value: String): FlexibleDate<String> = FlexibleDate-String(value)
        @JvmStatic()
        @JvmName("from")
        operator fun invoke(value: Double): FlexibleDate<Double> = FlexibleDate-Double(value)
        @JvmStatic()
        @JvmName("from")
        operator fun invoke(value: LocalDate): FlexibleDate<LocalDate> = FlexibleDate-LocalDate(value)
    }
    //Compiler generated classes, unusable directly from Kotlin or Java. Naming is arbitrary.
    data class FlexibleDate-String(override val value: String) : FlexibleDate<String>()
    data class FlexibleDate-Double(override val value: Double) : FlexibleDate<Double>()
    data class FlexibleDate-LocalDate(override val value: Double) : FlexibleDate<LocalDate>()

A function defined as:

fun typeAsString(date: FlexibleDate) = when (date) {
        is String -> "String"
        is Double -> "Double"
        is LocalDate -> "LocalDate"
}

could be compiled to the equivalent of:

fun typeAsString(date: FlexibleDate<*>) = when (date.value) {
        is String -> "String"
        is Double -> "Double"
        is LocalDate -> "LocalDate"
}

A call site such as:

val typeString = typeAsString("March 1st")

could be compiled to the equivalent of:

val typeString = typeAsString(FlexibleDate-String("March 1st"))

and could be called from Java with:

String typeString = MyFileKt.typeAsString(FlexibleDate.from("March 1st"))

If the assigned to property is implicitly typed but the user wants it to be of a union type:

val unionType = FlexibleDate("March 1st") // unionType is a FlexibleDate

It is an error to check if an expression is of a union type

fun doSomething(val obj: Any) {
    when (obj) {
        is String -> handleString(obj) //This would be called for FlexibleDate(“March 1st”)
        is FlexibleDate -> handleDate(obj) //This is an error, not possible
    }
}
doSomething(FlexibleDate(“March 1st”)) // “March 1st” not FlexibleDate is passed in.

Assigning from one union type to another would involve a when. For example:

//union class FlexibleDate = String|Number|LocalDate
//union class FlexibleDateInput = String|Number

var date: FlexibleDate = ...
fun setFlexibleDateFromInput(input: FlexibleDateInput) {
    date = input
}

would be compiled to the equivalent of:

var date: FlexibleDate = ...
fun setFlexibleDateFromInput(input: FlexibleDateInput) {
    date = when (input.value) {
        is String: FlexibleData-String(input.value)
        is Number: FlexibleData-Number(input.value)
}

Casting follows all the same rules as assignment.

The basic rules:

  1. Any property or parameter of type U where U has @SealedUnion can be assigned a value of type T if there is an “operator fun invoke(value: T)” defined in U.Companion. It can also be assigned a value of type U. It can be assigned a value of another union type if an exhaustive when expression can be built to do the conversion.
  2. Any usage of a property or parameter of type U where U has @SealedUnion is treated as a call to “.value” on that parameter.
  3. It is an error to apply @SealedUnion explicitly to a class.
  4. It is an error to use a union type in an is expression

Compiler changes (that I can think of):

  1. Support union syntax
  2. Support simple assigning or casting to unions by calling invoke operator
  3. Support assigning or casting to unions from unions with when expression
  4. Support use site of unions by calling property automatically
  5. Disallow SealedUnion attribute use
  6. Disallow “is” check for union types
  7. Improved error messaging for any error where a union is involve to avoid messages that reference the generated code like “Ambiguous assignment to union cal MyUnion” instead of “Ambiguous call to operator fun invoke of class MyUnion.Companion”
  8. Let U be a sealed class with type parameter T where all instantiable subclasses of U specify a specific type for T. If expr is of type T, then the compiler tracks the possible types of expr just like it tracks the possible types for a sealed class. This allows for exhaustive when for expressions of type T.

Some rational:

  1. Naming: Named unions allows usage from Java. Any place where an anonymous union is used once as a method parameter, can be replaced with overloads. Any place where an anonymous union would be repeated, should generally be named anyways to reduce duplication.
  2. Implicit Conversions: The value is simply wrapped in a sealed class when passed around and unwrapped as-is when used. This is more like boxing than the implicit casts that manipulate values. Disallowing explicit use of @SealedUnion should prevent abuse for other purposes.
  3. Reusing compiler logic: The compiler already has the ability to check for when of sealed classes, it should be able to leverage that same logic for generic types of sealed classes.
  4. Type safety, while type erasure means that the actual type of the value is Object, factory methods of the sealed class holding the value prevents values of an invalid type as well as a sealed class does.
  5. Disallowing “is” checks: Because all usages of an expression of a union type actually access the value property, it is impossible to reference the sealed class instance itself just with Kotlin. So Kotlin expressions can never be of a union type. To reduce confusion, we simply disallow the expression. Note that if Java passed FlexibleDate.from(“hello”) to an Any parameter, the assumption would be broken but type safety would not be undermined.

Alternatives:
SealedUnion could be an interface instead of an attribute:

interface SealedUnion<T>: { val value: T }

Final thought:
Allowing exhaustive when where possible for expressions of type T where T is a type parameter of a sealed class could be done without any of the other language or compiler modifications. This could allow for developers to play around with scenarios involving expressions that are only known to be one of an enumerable set of types. Perhaps an opt-in could allow some exploration of these scenarios without all the overhead of the other language/compiler features needed for full union support.

#76

IMO, for union type (like for nullsafe), kotlin shouldn’t care about java interrop (until java integrate the concept), and adopt a simple syntax.

fun xxx(input : String | Double | LocalDate)

Compiler could translate it with Any type and a union annotation

fun xxx(@union(String::class, Double::class, LocalDate::class) input : Any)

This won’t stop java call like xxx(null) or xxx(3L) at compile time, but should failed at runtime.

Other wrong Kotlin code should be easy to control, and mixed union type and intersection type should be easier to integrate.

fun yyy(@union(String::class, @intersection(Cloneable::class, Serialisable::class)) input : Any)
1 Like
#77

Hi @nickallendev,
I read your post, it looks interesting.
I share with you some personal considerations.

This example works

fun typeAsString(date: FlexibleDate) = when (date) {
        is String -> "String"
        is Double -> "Double"
        is LocalDate -> "LocalDate"
}

Instead this code does not

fun typeAsString(date: FlexibleDate) :String {
  val dateAny = date as Any
  when (dateAny) {
        is String -> "String"
        is Double -> "Double"
        is LocalDate -> "LocalDate"
  }
}

Finally String, Double and LocalDate are Serializable, FlexibleDate not.

#78

Thanks for the interest @fvasco :slight_smile:

One of the rules is that all usages are actually value property accessors. So this does work:

fun typeAsString(date: FlexibleDate) :String {
  val dateAny = date as Any
  when (dateAny) {
    is String -> "String"
    is Double -> "Double"
    is LocalDate -> "LocalDate"
  }
}

because it’s not the actual FlexibleDate that is cast to Any, but rather the wrapped value. An actual instance of the underlying sealed class is never accessible from regular kotlin code (Java or reflection could access it though).

Something like a nested alias (like date: FlexibleDate.Union) or a use-site attribute (like @ExposeUnion date: FlexibleDate) could allow direct access but I’m unsure of a use case and it feels contrary to the purpose of the feature so I didn’t suggest it.

Thank you for pointing Serializable out! I’m not very familiar with that area of Kotlin (looking at the docs for first time now) so apologies if any of my understanding is wildly off.

It seems like some sort of built in support from the serialization plugin could be implemented for this and would be appropriate since serialization feels like a major potential use case for unions. Detecting @Serializable and @SealedUnion together could cause the plugin to create a custom serializer similar to PolymorphicSerializer.

Without a built in solution, allowing attributes to be specified on the hidden subtypes could allow unions to at least work as well as regular sealed classes:

union class FlexibleDate { @Serializable String, @Serializable Number, @Serializable LocalDate }

which applies @Serializable to FlexibleDate-String, FlexibleDate-Number, and FlexibleDate-LocalDate but seems like it would require @Polymorphic attributes for properties of union type in other Serializable class definitions.

#79

Hi @nickallendev,
I try to reconsider your proposal.

union class looks to me as inline sealed class, you are mixing some concept and your proposal -as is- can confuses the developer (and the compiler).

because it’s not the actual FlexibleDate that is cast to Any, but rather the wrapped value

This does not work, ie:

fun FlexibleDate.asAny() = FlexibleDate as Any

flexibleDate as FlexibleDate // always works
flexibleDate.asAny() as FlexibleDate // class cast exception?

Your proposal requires many compiler tricks and workaround to works, it is complex.
inline classes are simpler but it enought confusionary for developers (cast and wrap issues).

Maybe the goal for union classes is not reduce the code, but to provide a well defined type and a reasonable way to express it in ideomatic Kotlin.

I will rethink what have you write, removing all compiler tricks.

1 Like
#80

This proposal actual feels like the opposite of inline classes. With inline class, you use a wrapper class in Kotlin, but under the covers there is no wrapper at all. This proposal suggests not using a wrapper in the kotlin code but including a wrapper under the covers.

As for your confusion:

flexibleDate as Any

is really

flexibleDate.value as Any

and assigning/casting involves calling a companion object method if available so:

flexibleDate.asAny() as FlexibleDate

becomes

FlexibleDate.cast(flexibleDate.asAny())

I did not include a cast method for converting Any to FlexibleDate in the proposal code but it’d be appropriate. Adding a castOrNull method would be appropriate also for “as?”. I guess these are more “compiler tricks” though :wink:

I appreciate not attempting just to reduce code. Sealed classes and/or method overloads do generally satisfy the use cases of unions, but they do require a bit of boiler plate which can nudge developers into a less type safe direction. Specifically, I’m thinking of the inner value access, defining casting methods for Any, and defining conversion methods for sealed classes that represent a subset of values express-able by another sealed union.

#81

I made this propose translating the enum class to union class, enumerations are a single type with multiple values, all with same tye, union types are types with a single value of multiple types.

So the example

union class FlexibleDate { String, Number, LocalDate }

can become

sealed class FlexibleDate {
    inline class String(private val value: String) : FlexibleDate() { operator fun getValue() = value }
    inline class Number(private val value: Number) : FlexibleDate() { operator fun getValue() = value }
    inline class LocalDate(private val value: LocalDate) : FlexibleDate() { operator fun getValue() = value }
}


fun parse(text: String): FlexibleDate = TODO()

fun print(date: FlexibleDate) {
    when(date){
        is FlexibleDate.String -> println("String" + date.getValue())
        is FlexibleDate.Number -> println("Number" + date.getValue())
        is FlexibleDate.LocalDate -> println("LocalDate" + date.getValue())
    }
}

For gerValue see https://github.com/Kotlin/KEEP/blob/a8f082023fda207a73296e68d61916d2c94293aa/proposals/flexible-delegated-property-convention.md

#82

Union Type should allow this kind of code :

fun f1( x : String | Number | LocalDate ) : Unit { ... }

fun f2( y : String | Number) : Unit { f1(y) } // y is subtype of String | Number | LocalDate

fun f3( z : String | Number | URL ) : Unit
{
    if (z !is URL) { f2(z) } // ok, here, z is String | Number.
}

I’m not sure it would be easy to integrate in compiler with “union class”.

1 Like