I have a class that has multiple type parameters. ...
# getting-started
c
I have a class that has multiple type parameters.
Copy code
class Foo<A, B, C> internal constructor(…)
Users can only instantiate instances via a builder (①):
Copy code
val foo = Foo.new()
    .a(…)
    .b(…)
    .build()
All type parameters have default values. Depending on the values of the type parameters, users can pass these instances to different functions that I provide (②):
Copy code
doSomethingWith(foo) // different overload based on the type parameters
I also provide a type alias that has the common supertype for all type parameters (③):
Copy code
typealias AnyFoo = Foo<*, *, *>
My understanding is that adding a type parameter: • is a binary-compatible change, because type parameters are removed at compile-time anyway • is a source-incompatible change, because the user must specify it However, since: • The user doesn't need to write the type parameters when declaring variables, thanks to type inference from the builder output • The user doesn't need to write the type parameters when instantiating the class (①) nor when using the object (②) • The user in many cases can use the generic typealias, which is always source- and binary-compatible when type parameters are added (③) Would you consider it safe to add a type parameter to
Foo
in a minor semver release? How can I communicate to users that writing
Foo<something>
in their codebase will lead to breaking changes, but using type inference won't?
y
My only concern would be if someone defined a function that takes a
Foo
as a parameter or receiver. They'd be forced to specify type parameters then.
c
Yeah, this is my concern as well. Do you have any idea how to discourage them from doing that? It kind of feels like abuse to use
@Deprecated
for that. I'll need to re-read the
@RequiresOptIn
annotation but I believe it applies to all members of the class, not just the class name itself?
d
You want them to not be able to pass that variable around? Doesn't that kind of make it hard to use?
Also, I don't think you can overload based on type parameters, so I think you're going to be stuck on that part.
Is it possible to manage the types only in the builder, and have the actual type be non-generic?
c
Not the variable, I want them not to write the type explicitly. If they use type inference, and I add type parameters, nothing breaks. However, if they wrote the type somewhere, that will not compile anymore because a type parameter is missing.
Also, I don't think you can overload based on type parameters, so I think you're going to be stuck on that part.
You can 🙂
d
I feel like you're making your API too clever.
c
Is it possible to manage the types only in the builder, and have the actual type be non-generic?
The types carry information that the builder wants to provide to the other functions. For unrelated reasons, these functions need to have that information at compile-time (they are
inline
+
reified
), so I don't have a choice but to use type parameters.
d
Copy code
fun something(foo: Foo<A,B,C>)
and
Copy code
fun something(foo: Foo<X,Y,Z>)
Will both have the same type erased signature, so the overloading won't work.
c
I feel like you're making your API too clever.
Ahah yeah, that might be true.
Will both have the same type erased signature, so the overloading won't work.
If you use
@JvmName
etc, you can overload on type parameters even if the platform doesn't allow it.
d
Thought: Use the reified type in the builder, and then build the object that doesn't have the types exposed.
c
For more context: I'm building a sort of type-safe schema of your API in common code. Each variable you declare in common code will be used in two places: the client-side code, which makes the request, and the server-side code, which reads the request. Both writing and reading the requests must be
inline reified
because of KotlinX.Serialization. Thus, the only way to store the information is in the common object as a type parameter so it can be used as a constraint for both
inline reified
functions.
A very simplified version:
Copy code
// common code
val get = endpointBuilder()
    .withBody<Foo>()

// client-side
engine.request(get)
where that last function is implemented as
Copy code
suspend inline fun <reified B : Body> Engine.request(endpoint: Endpoint<B>) {
    // uses KotlinX.Serialization, etc
}
The big downside to this, and the main worry I'm facing, is that each time I want to add a new field to the common declaration, it adds another type parameter everywhere.
d
I don't actually know about
KotlinX.Serialization
, so I can't really make suggestions around that specifically.
Do the new fields all need to be individually serializable?
c
Well, all that's relevant here is that the function that serializes or deserializes data is
reified
, thus all utility methods built upon it must be
reified
as well.
> Do the new fields all need to be individually serializable? Even if they don't need to, if I don't add them as type parameters, I'm not aware of a way to constrain the types used on the callsite.
d
Using a lot of inline funs where the implementation could change is also going to cause headaches in the future. If someone upgrades the library without recompiling, the inlined funs will be the old version.
I've actually run into that problem personally.
c
Yes, that's true. I'm not planning on making binary-breaking changes to this, but sadly adding a type parameter is a source-breaking change.
d
Have you considered using something like:
data class TypeInfo<T>(val type: KClass<T>)
with a
inline fun <reified T> typeOf() = TypeInfo(T::class)
?
Basically, you're going to want to narrow the scope of where you want reified types. Otherwise it will be a headache for users down the road.
I think I've given all the advice I can on this 😉. Good luck!
c
I'm not sure how that
TypeInfo
helps?
d
Ugh, I wrote it wrong.
Ignore what I said about TypeInfo, my coffee hasn't hit quite yet.
I was thinking of what Jackson and Spring use to handle reified generic types.
If you implement something with a similar pattern, you can then use it without reified methods.
c
Thanks, I'll look into it.
y
Is this library open source btw? I'd love to have a look and try some ideas out! If not, do you have a small example that I can experiment with?
c
Not yet, but it will be as soon as I'm convinced that it's worth doing 🙂
If you want to follow the progress, you can join https://discord.com/invite/48Wv6BkJx6 or just wait until https://gitlab.com/opensavvy/spine doesn't 404 😇
At the moment it looks like this (it's built to interop with Ktor)
y
Joined! In the meantime, I'll try to build an example of this, but it could be cool to try a singular type parameter that itself has type parameters, and then mark that class as
RequiresOptIn
. Something like
Foo<TypeWrapper<A, B, C>>
and
typealias AnyFoo = Foo<*>
(so that the alias doesn't need opt in)
c
Ohh, that's a good idea!
y
RequiresOptIn
doesn't work, but this does (It's a little bit of an abuse of
Deprecated
):
Copy code
@Deprecated("Don't use", level = DeprecationLevel.HIDDEN)
sealed interface TypeWrapper<A, B, C>
class Endpoint<T>
typealias AnyEndpoint = Endpoint<*>
    
@Suppress("DEPRECATION_ERROR")
inline fun <reified A, reified B, reified C> Endpoint<TypeWrapper<A, B, C>>.useTypes() {
    println("${A::class} ${B::class} ${C::class}")
}

@Suppress("DEPRECATION_ERROR")
fun endpoint(): Endpoint<TypeWrapper<Unit, Unit, Unit>> = Endpoint<TypeWrapper<Unit, Unit, Unit>>()

@Suppress("DEPRECATION_ERROR")
fun <New, A, B, C> Endpoint<TypeWrapper<A, B, C>>.a() = Endpoint<TypeWrapper<New, B, C>>()
@Suppress("DEPRECATION_ERROR")
fun <New, A, B, C> Endpoint<TypeWrapper<A, B, C>>.b() = Endpoint<TypeWrapper<A, New, C>>()
@Suppress("DEPRECATION_ERROR")
fun <New, A, B, C> Endpoint<TypeWrapper<A, B, C>>.c() = Endpoint<TypeWrapper<A, B, New>>()

fun main() {
    val myEndpoint = endpoint()
        .a<String, _, _, _>()
        .b<Int, _, _, _>()
        .c<List<*>, _, _, _>()
    myEndpoint.useTypes()
}
This method would still allow someone to define a function on
Endpoint<T>
, but it won't allow them to change the type within
TypeWrapper
. As long as you define your functions sufficiently, it should be fine.
c
Ohh, this one's interesting. I always forget that you can suppress DeprecationLevel.HIDDEN
Now that we know it's possible, time to wonder if it's a good idea 🤣
😂 1
y
Btw, it might ruin some of your
request
methods by having to use
_
to infer types everywhere
c
At the very least, the places where you used _ in `main`are a non-issue, since the builder is code I own and is therefore allowed to make changes to the type parameters individually
y
Copy code
sealed interface AnyEndpoint
@Deprecated("Don't use", level = DeprecationLevel.HIDDEN)
class Endpoint<A, B, C>: AnyEndpoint {
    @Suppress("DEPRECATION_ERROR")
    fun <A> a() = Endpoint<A, B, C>()
    @Suppress("DEPRECATION_ERROR")
    fun <B> b() = Endpoint<A, B, C>()
    @Suppress("DEPRECATION_ERROR")
	fun <C> c() = Endpoint<A, B, C>()
}

@Suppress("DEPRECATION_ERROR")
inline fun <reified A, reified B, reified C> Endpoint<A, B, C>.useTypes() {
    println("${A::class} ${B::class} ${C::class}")
}

@Suppress("DEPRECATION_ERROR")
fun endpoint(): Endpoint<Unit, Unit, Unit> = Endpoint<Unit, Unit, Unit>()

fun AnyEndpoint.noSuppressionNeeded() { }

fun main() {
    val myEndpoint = endpoint()
        .a<String>()
        .b<Int>()
        .c<List<*>>()
    myEndpoint.useTypes()
}
A no-compromise solution. Well, users can't refer to the
Endpoint
type, but they shouldn't have anyway.
AnyEndpoint
has to be a supertype because `typealias`es also carry the Deprecation it turns out.
c
That was one of the first things I tried to do (alongside RequiresOptIn), and I believe it works fine in IntelliJ, but in the CLI it warns on every usage of every variable whose type is inferred to
Endpoint
, does it not?
I'll try again, I didn't have the supertype, maybe that fixes it
AnyEndpoint
has to be a supertype
Assuming this indeed works with no downsides, I don't think this is an issue. I think it's intuitive enough that it shouldn't cause issues.
Ok, it seems to work. I find fascinating that the exact same code without the parent interface (which is never used directly anywhere) leads to Deprecated warnings everywhere.
I would assume this is because the compiler has a heuristic that if it infers a type that is marked as deprecated but it has a non-
Any
supertype that isn't deprecated, then the value must come from the author of the deprecated type, who will in the future replace it by another type under the same supertype?