I’ve been playing around with a use-case of Intersection types in a trait-like manner and thought I’d post some interesting and currently available things you can do using the supported Kotlin intersection types.
My use-case is a type of object with mixed properties. I could go with a catch-all container, a TypedMap, or interface hierarchy, but the traits style of interface per property seems to fit for me (although not fully traits since there is no implementation).
My use-case
For my use-case, my consumer of the top-level type can check for subtypes, cast, and capture its required properties in a new containing object (that is a new intersection) for use within the consumer. So produces would put in their own type, an intersection of various trait interfaces, and the consumer would check and convert to another intersection type.
This is perfectly fine–but I decided to explore skipping the custom container for the consumer and directly working with the intersection types. What I learned was Kotlin supports a decent bit of direct intersection past the normal Java intersection support.
The standard Java/Kotlin intersection support.
Both Java and Kotlin support intersection types in the generic context.
I can define a function that requires a type of A & B
, where A
and B
like this:
open class A
interface B
class Both : A(), B
//sampleStart
fun <T> takeAandB(t: T) where T: A, T: B = println("Got $t")
//sampleEnd
fun main() = takeAandB(Both())
This function requires the caller knows the specific type. So in this case, we can receive a provided type as an intersection. No inspecting and creating intersection types out of thin air so far.
I didn’t bother checking but I expect this works for generic classes as well.
One limitation is that you cannot define multiple generic bounds. Meaning this won’t work:
//sampleStart
fun <T1, T2, R> intersection(): R where R: T1, R: T2 { TODO() }
//sampleEnd
fun main() = println("Compiled")
Kotlin Smart-casting
Smart-casting allows creating intersections without generics:
interface Interface1 {
val i1: String
}
interface Interface2 {
val i2: Int
}
class Both : Interface1, Interface2 {
override val i1: String = "one"
override val i2: Int = 2
}
fun anyType(): Any = Both() // We mask the type here just to add another layer of "Any" for fun.
fun main() {
//sampleStart
val bar: Any = anyType()
bar as Interface1
bar as Interface2
// Try adding another type like `bar as String`. It will fail but the inspecting will show your interstion type.
bar // Check the type here in Intellij with `ctrl+shift+p`. Inspection bugs explained below.
println(bar.i1)
println(bar.i2)
//sampleEnd
}
I found inspecting the types with IntelliJ in this example behave a bit glitchy. For example, inspecting bar.i1
shows just Interface1
. If you ever want to see a true type of something, try making a new line with only the property you’re inspecting on it.
Kotlin contracts
We’re not limited to as
for smart-casting to create intersection types free of generics. Aside from using as?
with an expression that returns Nothing
, we can also use contracts to define our own intersection check:
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
inline fun <reified T1, reified T2> Any.asIntersection() {
contract {
returns() implies (this@asIntersection is T1 && this@asIntersection is T2)
}
assert(this is T1 && this is T2)
}
interface Interface1 {
val i1: String
}
interface Interface2 {
val i2: Int
}
interface Interface3 {
val i3: Boolean
}
class Both : Interface1, Interface2, Interface3 {
override val i1: String = "one"
override val i2: Int = 2
override val i3: Boolean = true
}
fun anyType(): Any = Both() // We mask the type here just to add another layer of "Any" for fun.
fun main() {
//sampleStart
val bar: Any = anyType()
bar.asIntersection<Interface1, Interface2>()
bar // Check the type here in Intellij with `ctrl+shift+p`
println(bar.i1)
println(bar.i2)
//sampleEnd
}
Personally, I don’t find this function more useful than multiple as
casts. But I guess it could serve as an example for a function with slightly different behavior that smart-casts a property to an intersection type.
The big limitation here is that we can’t return the intersection type due to smart-casting limitations with generics. Because of this, asIntersection()
may be a poor name since it’s really functioning as assertIntersection()
.
Here’s a version I find a bit more useful:
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
inline fun <reified T1, reified T2, reified T3> Any.isIntersection3(): Boolean {
contract {
returns(true) implies (this@isIntersection3 is T1 &&
this@isIntersection3 is T2 &&
this@isIntersection3 is T3
)
}
return (this is T1 &&
this is T2 &&
this is T3
)
}
interface Interface1 {
val i1: String
}
interface Interface2 {
val i2: Int
}
interface Interface3 {
val i3: Boolean
}
class Both : Interface1, Interface2, Interface3 {
override val i1: String = "one"
override val i2: Int = 2
override val i3: Boolean = true
}
fun anyType(): Any = Both() // We mask the type here just to add another layer of "Any" for fun.
fun main() {
//sampleStart
val bar: Any = anyType()
if (bar.isIntersection3<Interface1, Interface2, Interface3>()) {
// if (bar is Interface1 && bar is Interface2 && bar is Interface3) { // <-- alternative
bar // Check the type here in Intellij with `ctrl+shift+p`
println(bar.i1)
println(bar.i2)
println(bar.i3)
}
//sampleEnd
}
This function allows us to avoid typing bar
over and over again. Still not a ton over a few is
checks but you could use this to create a function with different behavior.
Kotlin defining an intersection property (local only)
So far we’ve seen how you can smart-cast a property into an intersection type. But what if you want to create a property that is, from the start, an intersection type? We can use variable inference!
interface Interface1 {
val i1: String
}
interface Interface2 {
val i2: Int
}
class Both : Interface1, Interface2 {
override val i1: String = "one"
override val i2: Int = 2
}
fun anyType(): Any = Both() // We mask the type here just to add another layer of "Any" for fun.
fun main() {
//sampleStart
val bar = run { // You could use other scope functions or delegation here.
val temp = anyType()
temp as Interface1
temp as Interface2 // Can't return this because we haven't finished the smart-cast... bug?
temp // This is REQUIRED! See above.
}
bar // Check the type here in Intellij with `ctrl+shift+p`. Inspection bugs explained below.
println(bar.i1)
println(bar.i2)
//sampleEnd
}
NOTE: You can’t do this with member properties or with function return types. I haven’t fully understood why yet.
This last example makes more sense for my use-case in that it enables my consumer to skip defining its own intersection class (e.x. val things: List<ConsumerIntersection> = ...
) and instead can work with a collection of pure intersection: val things: List<(A & B & C & D)> = /* */
, but with the type inferred.
For clarity, my use-case used:
thingsProducer.mapNotNull { if (it.isIntersectionOf4<A, B, C, D>()) it else null }
Simple usage like smart-casting to a binary intersection or usage like generics with multiple concrete bonds is pretty easy. Once local properties primarily typed as intersections get involved things start to feel a bit hacky due to the possible bugs for inspection and having to return that property alone on the last line of a lambda.
I’m not sure if I have a point to this topic other than seeing if anyone finds it interesting… I know I did.
Maybe one thought might be: IMO the discussion of intersection types would be best done as separate from union types–easing some of the intersection limitations shouldn’t be shackled by the more difficult requests for union types.