Hey, all! I'm trying to be too clever for my own g...
# getting-started
j
Hey, all! I'm trying to be too clever for my own good and have generic'd myself into a corner. I have a function
Copy code
inline fun <reified T : Base> getThing() = 
  when (T::class) {
    A::class -> "something related to A"
    B::class -> "something related to B"
  }
Base
is a sealed interface, which looks like
Copy code
sealed interface Base {
  class A : Base
  class B : Base
}
Since
Base
is sealed, I feel like the compiler should be able to figure out that the
when
statement in
getThing()
is exhaustive and not require me to add an
else
arm, but it's not working like I'm expecting. This example code will not compile unless I add an
else
arm. My intention here was that when I eventually add a variant
C
to the structure, the compiler would be able to notify me if I forgot to add the corresponding arm to the
when
statement in
getThing()
but being forced to add an
else
there completely defeats this. I'm relatively new to Kotlin, is there something I'm missing here?
j
What if you call
getThing<Base>()
?
j
Thaaat's a good question. Maybe
Copy code
fun <T : Base> getThing(type: KClass<T>) = when (type) {
  A::class -> "something related to A"
  B::class -> "something related to B"
}
is nicer
Though it's still asking me for an else branch
j
What I mean is that the class can still be
Base
, so neither
A
nor
B
j
I hope not, since
Base
is an interface and not a class
j
Interfaces are also represented with
KClass
j
shoot
j
What are you trying to achieve here? Do you have some sort of heterogeneous data structure that you access using keys that are types?
Because if it's just executing some type-specific function, you can use a method in your interface (proper polymorphism) or extensions (if you don't control the original interface)
j
The actual problem I'm trying to solve is "we're using
String
to carry primary keys/IDs for various assets all over the place". This keeps leading to bugs where arguments end up in the wrong place in ways that are not quite obvious (e.g.
OrderAccess.getByPaymentId(orderId)
is a piece of code that accidentally snuck in as part of a refactor and lead to confusion when an API endpoint was suddenly returning no data). Most of the time these bugs are caught by testing or code reviews, but I'm experimenting with a
value class AssetId<T : IdType>(val inner: String)
where
IdType
is essentially
Base
from my example code. My hope is that I can replace e.g.
OrderAccess.getByPaymentId(id: String)
with
OrderAccess.getByPaymentId(id: AssetId<Payment>)
, allowing the compiler/type system to catch this type of mistake at compile time. So far so good, but I wanted to build on this to also add some run-time safety. All our asset IDs are prefixed, i.e. a paymentId will always start with
OA_PMNT_
. I want to leverage this to add run-time checks to
AssetId
by adding a companion
getPrefix<T : IdType>()
and checking that the string being wrapped in an
AssetId
has the expected prefix in an init block. This is where I'm stumped, since the type information needed is erased at runtime unless the type parameter is reified, and I can't do that on the constructor of a value class blob sweat smile
It's very possible I'm going about this the wrong way and should be using some sort of polymorphism where it looks more like
data class PaymentId(/* ... */) : AssetId
instead
c
Why not stay simpler and have a value class for each type with its pre-requisites?
Copy code
interface Id<T> {
    fun toString(): String
}

value class PaymentId(val inner: String) : Id<Payment> {
    init {
        require(inner.startsWith("OA_PMNT_")) { "…" }
    }
    override fun toString() = inner
}

value class SomethingElseId(val inner: String) : Id<SomethingElse> {
    // …
}
Reading this, I'm wondering if you're going too far into searching for a general solution. Regular polymorphism with a regular interface and one implementation for each case (especially since they have different requirements) seems more appropriate to me.
☝️ 1
j
I agree with Clovis about keeping it simple and just using an
init
block for checks. You might not even need the parent
Id<T>
interface, if you never need generic functions for accessing a
T
from an
Id<T>
(for instance your
getPaymentById(id: PaymentId)
is not generic and doesn't need the generic type from the ID)
So my question is, do you need a parent type for your IDs at all? Aren't you always using one specific type of ID?
j
You're right, my thinking with the parent type was to have that be the home for the prefix checks so I do not have to repeat the same
init
block for every different ID type (there's a handful of these). Value classes seem to a good solution since they "disappear" at run-time, I'll need to experiment further
Thank you both for your thoughts!
🤗 1
j
You could use a parent class for this, indeed, but you'll still have to pass the prefix from the child classes. So all in all I think it's not worth the complexity. If you really want to avoid repeating the
require
+
startsWith
, extracting a simple function that asserts a prefix should do.
p
We have a similar problem that was solved with value classes with private constructors and a common abstract supertype for the companion object with factor methods (and invoke)
j
But value classes cannot extend an abstract class, can they?
p
No, but the companion object can
j
Thank you, happy to hear I'm not the first person trying to work something like this out
j
Ah ok I misread what you meant
j
Fascinated to learn companion objects can have supertypes!
p
We do have to pass the private constructor as a reference to the companion object supertype to allow it to "construct" instances.
j
Do you think you could provide even just a pseudo code example of that? I've been wanting to have private constructors but have not been able to figure out how to do so since I needed to call the constructor from inline functions, requiring the constructors to be public
Regardless, this has given me a lot to chew on. Thank you!
p
we also bundle a
KSerializer<T>
impl in the companion object
j
Very very cool, thanks a lot