Here’s a bit of a crazy idea: “transparent” value class
es (I’m asking this here first to gather some feedback because currently I really don’t think this is a good proposal at all lol)
Simply, if there’s a value class with 1 parameter, you could add a transparent
modifier to it so that, whenever you have a value of that value class, it acts like if it is of the aforementioned type, but you may still access the functions that the value class itself provides. In a way, this is just a way to provide scoped extension functions, which I admit is practically solved by multiple receivers already, but there’s some use-cases that don’t quite fit that bill. Let me first just start with a simple example that is solved by context receivers:
transparent value class NumericalString private constructor(val underlying: String) {
constructor(value: Int): this(value.toString())
... etc for the rest of the number types
val doubled: String get() = "${underlying.toDouble() * 2}"
}
fun main() {
val fortyTwo = NumericalString(42)
val four = fortyTwo.doubled.lastLetter()
println(four + fortyTwo)
}
fun String.lastLetter() = last()
Re-writing this with context receivers is messy-ish, but it’s kind of doable:
interface NumericalStringOperations {
val String.doubled: String
companion object Default: NumericalStringOperations {
override val String.doubled: String get() = this.toDoubleOrNull()?.let { "${it * 2}" } ?: this
}
}
fun main() {
with NumbericalStringOperations:
val fortyTwo = "42"
val four = fortyTwo.doubled.lastLetter()
println(four + fortyTwo)
}
fun String.lastLetter() = last()
The issue is, however, that first of all this is not typesafe, since it has no idea if a String is numerical or not. The second issue is a bit niche, which is that what if you want to shadow a function that already exists:
transparent value class NumericalString private constructor(val underlying: String) {
constructor(value: Int): this(value.toString())
... etc for the rest of the number types
val doubled: String get() = "${underlying.toDouble() * 2}"
// Kind of assuming that no boxing will really happen and that the compiler is smart enough to not force `other` to be boxed
// The point of this is that "42" and "42.0" should be considered equal
override inline fun equals(other: Any?) = if(other is NumericalString) other.underlying.toDouble() == underlying.toDouble() else underlying == other.underlying
fun last(): NumericalString = NumericalStirng(underlying.last())
}
fun main() {
val fortyTwo = NumericalString(42)
val four = fortyTwo.doubled.last()
val doubleFour = NumericalString(4.0)
println("$four == $doubleFour returned ${four == doubleFour}") // Should be true
}
In fact, this absolutely can’t be done with context receivers (right now) because @HidesMembers
is limited in its usage to a handful of functions, and even if you do hack around the compiler to allow it to work everywhere (which I did try), it still doesn’t resolve the shadowing correctly because call resolution with @HidesMembers
only takes into account normal static extensions and not member extensions. Even if @HidesMembers
was extended properly to handle this, I still believe that making it a first-class citizen in Kotlin gives it more validity, if you will.
So far, the case I’ve mentioned is basically just a refined type, but this feature can extend beyond that. For example, let’s take a detour into functional programming with a simple Either<A,B>
perhaps like this:
class Failure<A>(val exception: A)
transparent value class Either<out A, out B> private constructor(val underlying: Any?) {
companion object {
fun <B> Right(value: B) = Either<Nothing, B>(value)
fun <A> Left(exception: A) = Either<A, Nothing>(Failure<A>(exception))
}
val isRight: Boolean get() {
contract {
returns(true) implies (underlying is B)
returns(false) implies (underlying is Failure<A>)
}
return underlying !is Failure<*>
}
val isLeft: Boolean get() {
contract {
returns(false) implies (underlying is B)
returns(true) implies (underlying is Failure<A>)
}
return underlying is Failure<*>
}
// map, flatMap, etc.
}
// User code
class NetworkFailure(val causeOfError: String)
class Information(val information: String)
fun networkCall(shouldBeSuccessful: Boolean): Either<NetworkFailure, Information> = if(shouldBeSuccessful) Either.Right(Information("super secret user data")) else Either.Left(NetworkFailure("No internet"))
fun main() {
val result = networkCall(true)
// Smart casting go brrrrr...
if(result.isRight) {
println(result.information)
} else {
println(result.exception.causeOfError)
}
}
Now, the Arrow folks already kind of tried to do with, but with computational blocks. The only issue with them is that they’re kind of friction-y to use since you have to call .bind()
on the returned result to get the value. However, this feature so far doesn’t actually solve their use case because it has a missing piece: verification. What if, with an operator verify
, a value class can prove that its underlying value is of a specific type. To continue with the arrow use-case, consider this:
transparent value class Either<out A, out B> private constructor(val underlying: Any?) {
// Same code from before
...
context(EitherEffect<A>) // Can't quite remember the Arrow implementation but it is something like that
operator fun verify() { // This would be auto-magically called everytime the value class is used as its underlying type
contract {
returns() implies (this@Either is B)
}
if(isLeft) throw ShortCircuitException(underlying)
}
}
// User code
class NetworkFailure(val causeOfError: String)
class Information(val information: String)
fun networkCall(shouldBeSuccessful: Boolean): Either<NetworkFailure, Information> = if(shouldBeSuccessful) Either.Right(Information("super secret user data")) else Either.Left(NetworkFailure("No internet"))
fun main() {
val result = networkCall(true)
// Arrow computational block
either {
println(result.information)
println(networkCall(false).information)
// This never actually executes because the top one throws
println(networkCall(true).information)
}
}
or let’s go even crazier. What if there’s an operator fun transparentlyCoerce
(long name to avoid clashes) that can be used for any class (not only value classes) and can have different implementations depending on context parameters. Then, that can be defined for Either like this:
operator fun transparentlyCoerce(): B = if(isLeft) throw ShortCircuitException(underlying) else underlying
and in fact, have you noticed that Failure is kind of ugly? You need to call its exception property every time, what if you don’t have to…:
operator fun <A> Failure<A>.transparentlyCoerce(): A = exception
and yes, I know I know, coercing values is very frowned-upon in Kotlin, but what if it is just coercing one type to another? As in, it is a one-to-one relationship, not a one-to-many. I believe that would be more sound, right? If transparentlyCoerce
is tooooo crazy, then still transparent value class
would fit well with Kotlin because it is opt-in, as in it can’t be defined using extensions or anything else like that, and so it only works in the case of the value class explicitly declaring itself as being transparent.
All feedback is very welcome, and again I don’t actually believe this will be considered as-is, but I feel like there’s some merit to discussing this until we reach something that actually feels idiomatic for Kotlin.