I’ve been experimenting with the experimental supp...
# arrow
l
I’ve been experimenting with the experimental support for context parameters released in Kotlin 2.1.20 in conjunction with Arrow’s Raise pattern. I’m struggling to find an ergonomic pattern and I’m curious as to what the Arrow maintainers have in mind here.
🎉 2
arrow intensifies 3
K 2
The Arrow docs include a section suggesting that, in the future, context parameters should let us define functions like this:
```context(_: Raise<UserNotFound>)
fun User.isValid(): Unit =
ensure(id > 0) { UserNotFound("User without a valid id: $id") }```
However, when using the experimental support for context parameters in Kotlin 2.1.20, this syntax doesn’t compile. The compiler reports
ensure
as an unresolved reference.
Here are some approaches I’ve played with, along with my commentary: Context for Example
Copy code
import arrow.core.raise.Raise
import arrow.core.raise.ensure

data class Invalid(val message: String)
Approach 1: Adapted from the docs example, but it doesn’t compile. The compiler doesn’t infer that
ensure
should be available via the context parameter.
Copy code
context parameter
context(_: Raise<Invalid>) fun greeting1(personName: String): String {
  ensure(personName.isBlank()) { Invalid("Person name is empty") }
  return "Hello, $personName"
}
Approach 2a: Here, the
raise
instance is used to call its own raise method explicitly (
raise.raise
). This dual usage within the same context can be confusing, as it’s not immediately clear which part of the API is being utilized.
Copy code
context(raise: Raise<Invalid>) fun greeting2a(personName: String): String {
  if (personName.isBlank()) raise.raise(Invalid("Person name is empty"))
  return "Hello, $personName"
}
Approach 2b: This version uses the
ensure
helper on the
raise
instance. While it avoids the name shadowing, it only helps if you can constrain yourself to the
ensure*
helpers.
Copy code
context(raise: Raise<Invalid>) fun greeting2b(personName: String): String {
  raise.ensure(personName.isBlank()) { Invalid("Person name is empty") }
  return "Hello, $personName"
}
Approach 3a: By wrapping the code in a
with(raise)
block, you reduce repetition. However, this approach introduces extra verbosity and potential shadowing issues, as the
raise
object has the same name as the function being invoked.
Copy code
context(raise: Raise<Invalid>) fun greeting3a(personName: String): String {
  with(raise) {
    if (personName.isBlank()) raise(Invalid("Person name is empty"))
    return "Hello, $personName"
  }
}
Approach 3b: Similar to 2b but wrapped in a
with(raise)
block for slightly improved readability. However, it still relies on constraining yourself to the
ensure*
helpers to avoid the name shadowing.
Copy code
context(raise: Raise<Invalid>) fun greeting3b(personName: String): String {
  with(raise) {
    ensure(personName.isBlank()) { Invalid("Person name is empty") }
    return "Hello, $personName"
  }
}
👀 1
I’m curious, what pattern do you recommend for integrating context parameters with Arrow’s Raise pattern? Are there any nuances or upcoming changes in the experimental API that I should be aware of?
a
the best solution is already hinted in the KEEP https://github.com/Kotlin/KEEP/blob/context-parameters/proposals/context-parameters.md#simulating-receivers => creating a bunch of "bridge functions" exposing the API using context parameters in the meanwhile, I think that the best option is to simply use
context(raise> Raise<Int>)
and call
raise.raise(3)
explicitly, or use
with(raise)
if you feel that is too much repetition
thank you color 1
p
l
Thank you for the link, and the opinion! It was a good read. And I agree, the "bridge functions" approach seems to be a great solution.
Copy code
context(r: Raise<Error>) fun <Error> raise(error: Error): Nothing = r.raise(error)

@OptIn(ExperimentalContracts::class)
context(r: Raise<Error>) fun <Error> ensure(condition: Boolean, raise: () -> Error) {
  contract {
    callsInPlace(raise, AT_MOST_ONCE)
    returns() implies condition
  }
  return if (condition) Unit else r.raise(raise())
}

@OptIn(ExperimentalContracts::class)
context(r: Raise<Error>) fun <Error, B : Any> ensureNotNull(value: B?, raise: () -> Error): B {
  contract {
    callsInPlace(raise, AT_MOST_ONCE)
    returns() implies (value != null)
  }
  return value ?: r.raise(raise())
}

context(_: Raise<Invalid>) fun greeting4a(personName: String): String {
  if (personName.isBlank()) raise(Invalid("Person name is empty"))
  return "Hello, $personName"
}

context(_: Raise<Invalid>) fun greeting4b(personName: String): String {
  ensure(personName.isBlank()) { Invalid("Person name is empty") }
  return "Hello, $personName"
}

context(_: Raise<Invalid>) fun greeting4c(personName: String?): String {
  ensureNotNull(personName) { Invalid("Person name is empty") }
  return "Hello, $personName"
}
I’ll keep an eye on how this develops. I really appreciate your guidance and the context provided so far. Just to clarify—does Arrow plan to publish bridge functions like these as context parameters reach a stable release, or is that ultimately expected to be handled by the consumer?
a
we expect to publish this, we still need to iron out some details (same package? mark as experimental?). On top of that, I think we should wait to 2.2 to come out, but I can be persuaded otherwise
❤️ 3
thank you color 1
p
we've considered releasing the ktor client module as "experimental" - I think the same is safe to say for the context-params support module (if we have a separate module)
... I say "we" but I'm by no way authoritative 🙂
l
You guys are being very thoughtful, and I really appreciate that. Despite my eagerness to adopt these patterns, I was burned by jumping into context receivers prematurely, so personally I’m a fan of waiting for a stable Kotlin feature release before using them on anything critical. Thanks for sharing all the insights!