Hi, I'm trying to model api responses that can be ...
# arrow
d
Hi, I'm trying to model api responses that can be either successful with a specific type of data, network/server errors with throwables, or an api error with a specific type of data. I want to only write the code handling network and server errors once, so I thought I could do something like
Copy code
sealed class Error<T> {
    data class Network(val throwable: Throwable): ApiError()
    data class Server(val throwable: HttpException): ApiError()
    data class Api(val response: T)
}

fun register(email: String, password: String): Either<Error<RegisterError>, AuthTokens>
fun login(email: String, password: String): Either<Error<LoginError>, AuthTokens>
I thought that would let me keep Error.Network and Error.Server the same everywhere, and substitute in different Error.Api's for different responses. The problem is that apparently Kotlin generics don't work like that. I would need to do
Copy code
sealed class Error {
    data class Network(val throwable: Throwable): ApiError()
    data class Server(val throwable: HttpException): ApiError()
    data class Api<T>(val response: T)
}

fun register(email: String, password: String): Either<Error, AuthTokens>
fun login(email: String, password: String): Either<Error, AuthTokens>
So I lose the ability to specify what kind of Error.Api I return. Is there a way to avoid this?
a
I wouldn’t say you loose it, you can still do a
when
on the
Error
and see which type it is (that is one of the advantages of using sealed classes)
j
Why are members of your sealed class not extending the sealed class? This is not how to use sealed classes in Kotlin.
d
You're totally right @julian. It's because I mistyped
👍🏾 1
I think I do @Andrei Bechet ? I want to say register will return a success value, an ApiError.Server, an ApiError.Network, or an ApiError.Api<RegisterError>. I want a type safe way of guaranteeing the wrapped value in the ApiError.Api returned
a
Julian is right, I didn’t notice that.
j
@Daniel Try this:
Copy code
sealed class ApiError<out T> {

    data class Network<T>(val throwable: Throwable): ApiError<T>()

    data class Server<T>(val throwable: HttpException): ApiError<T>()

    data class Api<T>(val response: T): ApiError<T>()
}

fun <T> register(email: String, password: String): Either<ApiError<T>, AuthTokens>

fun <T> login(email: String, password: String): Either<ApiError<T>, AuthTokens>
Remove the
out
if you want
T
to be invariant. Otherwise
out
makes
T
covariant i.e.
T
or anything extending
T
can be contained in
ApiError
.
If you don't need
T
to be generic at the usage site (i.e.
Either<ApiError<?>>
, then you could do this instead:
Copy code
fun register(email: String, password: String): Either<ApiError<HeadlessChicken>, AuthTokens>
d
Thank you @Julian. I thought the <T> in each member meant that T was redefined, ie you could have an ApiError<HeadlessChicken>.Api<HeadedChicken>
j
👍🏾 Correct, that won't happen. You could have concrete types
ApiError.Api<HeadedChicken>
or
ApiError.Api<HeadlessChicken>
. But not a concrete type with
HeadedChicken
and
HeadlessChicken
at the same time.
In our implementation, the
T
gets passed up the inheritance chain.
d
Why is that? I the angular brackets named a type parameter @julian, and so it would be just like val T = 1, val T =2
j
I'm not sure I follow your question. Can you rephrase it?
d
Why isn't that true?
j
I don't know the specifics, but I think it has something to do with scope. Just like variables shadow if they are within the same scope, type variables with the same name (
T
) will too. But in the case of type variables, the compiler will assume that they are in fact the same. But here we have
T
in two different scopes - the sealed class and its subtype. You may prefer to use something like
T2
at the subtype level and
T1
at the sealed class level. That would work too. But it might obscure the fact that the type variables at both levels will ultimately end up with the same value i.e. the type argument you provide that satisfies the requirement at both levels.
d
If I use T1 at the sealed class level and T2 at the subtype level wouldn't it be valid for T1 != T2?
j
No. Because notice below how
T2
is passed as the argument i.e.
ApiError<T2>()
. By doing this, the value of
T1
becomes the value of
T2
.
Copy code
sealed class ApiError<out T1> {

    data class Network<T2>(val throwable: Throwable): ApiError<T2>()

    data class Server<T2>(val throwable: HttpException): ApiError<T2>()

    data class Api<T2>(val response: T2): ApiError<T2>()
}
d
Oh, I missed that! That also explains why the other member classes need a type parameter. Thank you for your help!
j
T1
and
T2
are just names. They end up with the same value.
You're welcome!
@Daniel It just occurred to me, simplify things further like this:
Copy code
sealed class ApiError<out T1> {
  data class Network(val throwable: Throwable): ApiError<Nothing>()
  data class Server(val throwable: Exception): ApiError<Nothing>()
  data class Api<T2>(val response: T2): ApiError<T2>()
}

fun main() {
  var e1: ApiError<Int> = ApiError.Network(Throwable())
  e1 = ApiError.Api<Int>(1)
}
Note how
Network
and
Server
no longer require a type argument when being instantiated because we
ApiError<Nothing>()
. Also note how
e1
can be assigned either an
Api
or a
Network
.
d
But is there a reason you used T1 & T2 instead of T in the example?
j
Nope. You can use T1 & T2, OR you can use T at both levels. Same thing we discussed earlier. In the second case, the fact that they are both named T shouldn't obscure the fact that they're different parameters - one is the type parameter on Api the other is the type parameter on ApiError. Api passes the value of its T to ApiError's T. So in the end all the Ts have the same value.
Nothing
extends every type, so the compiler allows Network and Server to be assignable to any variable typed as
ApiError<T>
regardless of what T is.
If your question is "Why bother with ApiError having a type parameter, when the only subtype that supplies a meaningful value is Api?", take a look at the following and see the effect of having the type parameter be present at the level of ApiError or not.
Copy code
sealed class ApiError<out T1> {  // Type parameter T1
  data class Network(val throwable: Throwable): ApiError<Nothing>()
  data class Server(val throwable: Exception): ApiError<Nothing>()
  data class Api<T2>(val response: T2): ApiError<T2>()
}
sealed class ApiError2 {  // No type parameter
  data class Network(val throwable: Throwable): ApiError2()
  data class Server(val throwable: Exception): ApiError2()
  data class Api<T2>(val response: T2): ApiError2()
}
fun main() {
  val e1: ApiError<Int> = ApiError.Api(1)
  when (e1) {
    is ApiError.Network -> TODO()
    is ApiError.Server -> TODO()
    is ApiError.Api<Int> -> TODO()
  }

  val e2: ApiError2 = ApiError2.Api(1)
  when (e2) {
    is ApiError2.Network -> TODO()
    is ApiError2.Server -> TODO()
    is ApiError2.Api<Int> -> TODO() // Compiler error: Cannot check for instance of erased type: ApiError2.Api<Int>
    is ApiError2.Api<*> -> when (e2.response) {
      is Int -> TODO()
      else -> TODO()
    }
  }
}
You can see that having the type parameter at both levels of the sealed class makes for nicer ergonomics. Note the compiler error, which forces us to compare against
ApiError2.Api<*>
. And note how the
else
opens a very big hole that the compiler cannot protect against. What is the
else
case? Well, everything else in the world. Because it covers every type that's not
Int
. In contrast we have confidence, with the first
when
, that every case is covered.
d
Thank you for such a detailed answer. It makes sense now
j
Great! You’re welcome.