Daniel
10/02/2020, 10:54 AMsealed 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
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?Andrei Bechet
10/02/2020, 2:23 PMwhen
on the Error
and see which type it is (that is one of the advantages of using sealed classes)julian
10/02/2020, 3:47 PMDaniel
10/02/2020, 3:55 PMDaniel
10/02/2020, 3:58 PMAndrei Bechet
10/02/2020, 3:59 PMjulian
10/02/2020, 6:26 PMsealed 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
.julian
10/02/2020, 6:28 PMT
to be generic at the usage site (i.e. Either<ApiError<?>>
, then you could do this instead:
fun register(email: String, password: String): Either<ApiError<HeadlessChicken>, AuthTokens>
Daniel
10/02/2020, 6:33 PMjulian
10/02/2020, 6:48 PMApiError.Api<HeadedChicken>
or ApiError.Api<HeadlessChicken>
. But not a concrete type with HeadedChicken
and HeadlessChicken
at the same time.julian
10/02/2020, 6:49 PMT
gets passed up the inheritance chain.Daniel
10/02/2020, 6:49 PMjulian
10/02/2020, 6:51 PMDaniel
10/02/2020, 6:52 PMjulian
10/02/2020, 6:58 PMT
) 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.Daniel
10/02/2020, 7:02 PMjulian
10/02/2020, 7:06 PMT2
is passed as the argument i.e. ApiError<T2>()
. By doing this, the value of T1
becomes the value of T2
.
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>()
}
Daniel
10/02/2020, 7:08 PMjulian
10/02/2020, 7:08 PMT1
and T2
are just names. They end up with the same value.julian
10/02/2020, 7:08 PMjulian
10/02/2020, 8:06 PMsealed 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)
}
julian
10/02/2020, 8:07 PMNetwork
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
.Daniel
10/02/2020, 8:19 PMjulian
10/02/2020, 8:34 PMNothing
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.julian
10/02/2020, 10:31 PMsealed 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.Daniel
10/03/2020, 6:57 AMjulian
10/03/2020, 12:50 PM