· 5 min read Posted by Paul Hawke

Sealed Generics and SKIE

Sealed Classes and Generics are both very useful for Kotlin APIs, and very painful with KMP and Swift. One of SKIE's lesser known benefits is the restoration of using Sealed Classes with generics, along with proper type discovery in Xcode.

As a tool, SKIE brings a lot to the table in terms of individual features. I particularly like the fact that sealed classes become exhaustive enums and that Kotlin now integrates directly with Swift concurrency - two great features, but what happens when you bring them together?

Suppose I have a repository in my common code that allows me to monitor details of an upcoming flight. I’ll simulate calling a backend API using a Flow directly -

class FlightRepository {
    fun monitorFlight(id: Long): Flow<ApiResult<Flight>> {
        return flow {
            emit(ApiResult.Loading)
            delay(1000)
            emit(makeFlight(id, FlightStatus.AT_GATE))
            delay(1000)
            emit(makeFlight(id, FlightStatus.TAXI))
            delay(1000)
            emit(makeFailed(IllegalArgumentException("Hello!")))
            delay(1000)
            emit(makeFlight(id, FlightStatus.IN_FLIGHT))
            delay(1000)
            emit(ApiResult.Loading)
            delay(1000)
            emit(makeFlight(id, FlightStatus.TAXI))
            delay(1000)
            emit(makeFlight(id, FlightStatus.AT_GATE))
        }
    }
}
Since SKIE is pronounced like “sky” and I couldn’t resist the pun :)

the ApiResult is generically typed and wraps up loading, success and error states

sealed class ApiResult<out T : Any> {
    data object Loading : ApiResult<Nothing>()
    data class Failed(val code: Int, val cause: Throwable?) : ApiResult<Nothing>()
    class Success<T : Any>(val code: Int, val data: T) : ApiResult<T>()
}

Note that the generic has an upper bound of Any. If you aren’t very familiar with writing generic code, that’s the part after the T in ApiResult<out T:Any>. That says, basically, the generic type can be anything that extends from Any. In this case, we specify Any so the value can’t be null. If left unspecified, the Kotlin compiler assumes Any? as the upper bound.

Also note the out. That’s a bit beyond the scope here, but in summary, that allows us to use the Nothing type values for the sealed class entries that don’t actually define the generic. Specifically, Loading and Failed. Neither has the data value, so defining the generic type isn’t really useful.

Finally, for completeness, here’s the rest:

data class Flight(
    val id: Long,
    val flightStatus: FlightStatus,
    val departureTime: Long,
    val arrivalTime: Long,
    val departureTZ: String,
    val arrivalTZ: String,
)

enum class FlightStatus {
    AT_GATE, TAXI, TAKEOFF, IN_FLIGHT, LANDING
}

fun makeFlight(id: Long, status: FlightStatus) = ApiResult.Success(Random.nextInt(), Flight(id, status, /* You'll need to hard code the rest :) */))
fun makeFailed(cause: Throwable?) = ApiResult.Failed(Random.nextInt(), cause)

Swift Code

Let’s first add some helper methods to print our state. These are simple. They take the types you’d expect (or none, for loading).

func showLoading() {
    print("Loading...")
}

func showSuccess(success: ApiResultSuccess<Flight>) {
    let flight : Flight = success.data
    print("##> Code: \(success.code) for Flight ID: \(flight.id) - \(flight.flightStatus)")
}

func showError(failed: ApiResultFailed) {
    print("Error: \(String(describing: failed.cause))")
}

Traditional KMP

The Kotlin result would have had to pass through an Objective-C layer on its way to my Swift / SwiftUI native app. This would have broken apart the sealed class and in the process lost the generic types.

Accessing sealed classes from Swift, without SKIE, usually requires knowing the types in advance, and casting them appropriately (see the SKIE Demo for an example). With generics, that cast becomes more difficult, or at least less safe. It actually took a while to figure this out, but we got it (thanks Tadeas).

for try await apiCallResult in repo.monitorFlight(id: 1234) {
    switch apiCallResult as NSObject {
    case let failed as ApiResultFailed:
        showError(failed: failed)
    case let success as ApiResultSuccess<Flight>:
        showSuccess(success: success)
    case let loading as ApiResultLoading:
        showLoading()
    default:
        print("Umm....")
    }
}

The key thing that was needed here was to cast apiCallResult to NSObject. Without that, the case comparisons would fail for ApiResultFailed and ApiResultLoading. Also, of course, we need default because sealed classes have no special meeting to Swift.

This is also a good time to point out that the code above is cheating a bit. It’s using SKIE’s flow support to loop over the values. Either another Coroutines to Swift translation would be needed, and/or I would have had to write some ugly code to work around these issues.

Yes, there are two paths you can go by, but in the long run

There’s still time to change the road you’re on

--- Led Zeppelin

Enter SKIE

Add SKIE to the module that is exporting the Xcode Framework that your Swift code will call:

plugins {
    // Etc ...
    id("co.touchlab.skie")
}
By default, all of SKIE’s features are enabled globally, except default arguments, which need to be added by annotations, or enabled in your Gradle build file.

Now in Xcode, let’s see how the Swift code changes:

func watchFlightData(repo: FlightRepository) async {
    for try await apiCallResult in repo.monitorFlight(id: 1234) {
        switch onEnum(of: apiCallResult) { // Call SKIE's onEnum
            case .failed(let err):
                showError(failed: err)
            case .success(let result):
                showSuccess(success: result) // No casting necessary...
            case .loading:
                showLoading()
        }
    }
}

and voila - messages appearing every second while the Flow was emitting -

Loading...
##> Code: -2046587601 for Flight ID: 1234 - atGate
##> Code: 554291271 for Flight ID: 1234 - taxi
Error: Optional(kotlin.IllegalArgumentException: Hello!)
##> Code: 90571325 for Flight ID: 1234 - inFlight
Loading...
##> Code: -423600031 for Flight ID: 1234 - taxi
##> Code: 1382812053 for Flight ID: 1234 - atGate

There’s a lot going on here!

  • I have type safety - my Flight object, declared as a generic type on the ApiResult, was preserved by SKIE between Kotlin and Swift; I was able to use the ID and status from my Flight as well as the code from the Success.
  • I can switch on the API result - the sealed class became an exhaustive enum.
  • Concurrency - SKIE integrated seamlessly with Swift, letting me await the result from the Kotlin flow returned from monitorFlight() function.

While switch onEnum(of:... is specific to SKIE, the result is a proper enum that not only feels native to Swift, but preserves type information and helps ensure better type safety. Developers writing Kotlin, with SKIE, can safely resume using some of the most important modern features of Kotlin. Sealed Classes (and Interfaces), with generics, are back on the table!