Kotlin - make multiple sealed classes have common set of "base" subclasses, but each could still add it's specific ones

1.2k views Asked by At

This question has a wider scope than Extract common objects from sealed class in kotlin and Android - How to make sealed class extend other sealed class? so it's not a duplicate of these

I have multiple sealed classes that represent results of various API calls. Each of these calls has a common set of expected results (success, network error, unexpected error), but each could introduce it's own result types (like 'user not found' or 'wrong ID').

To avoid copying same subclasses to each of sealed class, I want to create a "base" type that includes all common result types, while each sealed class could add it's specific subclasses:

interface BaseApiCallResult {
    data class Success(val data: String) : BaseApiCallResult
    data class UnexpectedError(val error: Throwable) : BaseApiCallResult
    data class NetworkError(val error: ApolloException) : BaseApiCallResult
}

sealed class ApiCallResult1 : BaseApiCallResult {
    data class WrongID(val id: Int) : ApiCallResult1()
}

sealed class ApiCallResult2 : BaseApiCallResult {
    data class UserDoesNotExist(val userid: Long) : ApiCallResult2()
}

sealed class ApiCallResult3 : BaseApiCallResult {
    data class NameAlreadyTaken(val name: String) : ApiCallResult3()
}

the problem is that subclasses in "base" cannot be treated as "child" classes:

fun apiCall1(): ApiCallResult1 {
    // won't compile, since BaseApiCallResult.UnexpectedError is not ApiCallResult1
    return BaseApiCallResult.UnexpectedError(Exception(""))
}

fun useApi() {
        when(val result = apiCall1()) {
            is ApiCallResult1.WrongID -> {  }
            // compile error: Incompatible types
            is BaseApiCallResult.Success -> {  }
            is BaseApiCallResult.UnexpectedError -> {  }
            is BaseApiCallResult.NetworkError -> {  }
        }
    }

solution from Android - How to make sealed class extend other sealed class? might be applied here, but for big number of sealed classes (I expect I might need several dozen of such classes) it becomes rather hacky

interface BaseApiCallResult {
    data class Success(val data: String) : Everything
    data class UnexpectedError(val error: Throwable) : Everything
    data class NetworkError(val error: ApolloException) : Everything
}

sealed interface ApiCallResult1 : BaseApiCallResult {
    data class WrongID(val id: Int) : ApiCallResult1()
}

sealed interface ApiCallResult2 : BaseApiCallResult {
    data class UserDoesNotExist(val userid: Long) : ApiCallResult2
}

sealed interface ApiCallResult3 : BaseApiCallResult {
    data class NameAlreadyTaken(val name: String) : ApiCallResult3
}

// adding each new sealed interface here seems like a hack
interface Everything : BaseApiCallResult, ApiCallResult1, ApiCallResult2, ApiCallResult3

Additionally, with above solution, every when {...} complains about Everything case not being handled. I could resign from using Everything, but then I have to list all interfaces in each "base" subclass, which is simply terrible:

// just imagine how would it look if there were 30 ApiCallResult classes
interface BaseApiCallResult {
    data class Success(val data: String) : BaseApiCallResult, ApiCallResult1, ApiCallResult2, ApiCallResult3
    data class UnexpectedError(val error: Throwable) : BaseApiCallResult, ApiCallResult1, ApiCallResult2, ApiCallResult3
    data class NetworkError(val error: ApolloException) : BaseApiCallResult, ApiCallResult1, ApiCallResult2, ApiCallResult3
}

Is there a better way to handle this kind of situation ?

2

There are 2 answers

0
Viacheslav Smityukh On

You have to separate ApiResult from ApiMethodResult they should not to be relatives.

Kotlin already has type Result and you can use it:

sealed interface ApiCall1Result {
    class WrongID : ApiCall1Result
    class UserInfo(val userId: Int) : ApiCall1Result
}

fun api1() : Result<ApiCallResult>

fun useApi1() {
    val result = api1()
    if(result.isFailure) {
        handle failure
    } else {
        val apiResult = result.getOrThrow()
        when(apiResult) {
            is WrongID -> {}
            is UserInfo -> {}
        }
    }
}

Or you can implement it by your self:

interface ApiResult<in T> {
    class Success<T : Any>(val data: T) : ApiResult<T>
    class Fail(val error: Throwable) : ApiResult<Any>
}

sealed class ApiCallResult1 {
    class WrongID(val id: Int) : ApiCallResult1()
    class UserInfo(val id: Int, val name: String) : ApiCallResult1()
}

fun apiCall1(): ApiResult<ApiCallResult1> {
    return ApiResult.Fail(Throwable())
}

fun useApi() {
    when (val result = apiCall1()) {
        is ApiResult.Fail -> {}
        is ApiResult.Success -> when (result.data) {
            is ApiCallResult1.WrongID -> {}
            is ApiCallResult1.UserInfo -> {}
        }
    }
}
0
Tenfour04 On

You could create a generic type for the sealed interface, and this type gets wrapped by one additional child class:

interface ApiCallResult<out O> {
    data class Success(val data: String) : ApiCallResult<Nothing>
    data class UnexpectedError(val error: Throwable) : ApiCallResult<Nothing>
    data class NetworkError(val error: ApolloException) : ApiCallResult<Nothing>
    data class Other<out O>(val value: O): ApiCallResult<O>
}

Then you can define your other callback types using a specific class as the O type:

data class UserDoesNotExist(val userid: Long)

fun handleApiCallResult2(result: ApiCallResult<UserDoesNotExist>) {
    when (result) {
        is ApiCallResult.Success -> {}
        is ApiCallResult.UnexpectedError -> {}
        is ApiCallResult.NetworkError -> {}
        is ApiCallResult.Other -> {
            // do something with result.value
        }
    }
}

When you have more than one other case, you can create a sealed interface to be the parent of those other cases, but you'll unfortunately need a nested when to handle them.

When you have no other cases, you can use ApiCallResult<Nothing> as your response type, but you'll unfortunately need to leave a do-nothing {} branch for the Other case. Or you could set up a separate sealed interface like in your long-winded final solution in your question, which is manageable because it would never grow to more than two sealed types.