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