· 5 min read Posted by Paul Hawke
Sealed Generics and SKIE
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))
}
}
}
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")
}
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 theApiResult
, was preserved by SKIE between Kotlin and Swift; I was able to use the ID and status from myFlight
as well as the code from theSuccess
. - 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, with generics, are back on the table!