Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Figuring out custom DSLs #161

Open
CLOVIS-AI opened this issue Apr 5, 2023 · 4 comments
Open

Figuring out custom DSLs #161

CLOVIS-AI opened this issue Apr 5, 2023 · 4 comments

Comments

@CLOVIS-AI
Copy link
Contributor

First, thanks a lot for this update! The possibility to create custom DSLs is amazing. That said, I'm having some issues using them.

I have a library which has its own Either-like type, called Outcome, except the left side must implement the Failure interface. I'm trying to create a custom DSL for it so Arrow users can use it as if it was Either:

interface Failure { … }

sealed class UserError : Failure {
    object NotFound : UserError()
    //
}

sealed class Outcome<out F : Failure, out T> {
    class OutcomeFailure<F : Failure>(val failure: F) : Outcome<F, Nothing>
    class OutcomeSuccess<T>(val value: T) : Outcome<Nothing, T>
}

Declaring the DSL itself was quite easy following the documentation:

@JvmInline
value class OutcomeDSL<F : Failure>(private val raise: Raise<Outcome<F, Nothing>>) :
    Raise<Outcome<F, Nothing>> by raise {
    
    fun <T> Outcome<F, T>.bind(): T = when (this) {
        is OutcomeFailure -> raise.raise(this)
        is OutcomeSuccess -> value
    }
}

@OptIn(ExperimentalTypeInference::class)
inline fun <F : Failure, T> out(@BuilderInference block: OutcomeDsl<F>.() -> T) : Outcome<F, T> =
    recover(
        block = { OutcomeSuccess(block(OutcomeDsl(this))) },
        recover = ::identity,
    )

In simple cases, it seems to work fine, however it quickly gets stuck on variance errors:

data class User(val name: String)

fun create(user: User?) = out<UserError, User> {
    ensureNotNull(user) { UserError.NotFound } // Type mismatch, expected OutcomeFailure, found UserError.NotFound

    TODO()
}

It seemed weird to me that it was expecting the OutcomeFailure type instead (why is it a Raise<Outcome<F, Nothing>> and not a Raise<F>?), so I tried to rewrite it as follows:

@JvmInline
value class OutcomeDsl<F : Failure>(private val raise: Raise<F>) :
    Raise<F> by raise {

    fun <T> Outcome<F, T>.bind(): T = when (this) {
        is OutcomeSuccess -> value
        is OutcomeFailure -> raise.raise(failure)
    }
}

@OptIn(ExperimentalTypeInference::class)
inline fun <F : Failure, T> out(@BuilderInference block: OutcomeDsl<F>.() -> T) : Outcome<F, T> =
    recover(
        block = { OutcomeSuccess(block(OutcomeDsl(this))) },
        recover = { e: F -> OutcomeFailure(e) },
    )

Note how:

  • OutcomeDsl.raise is a Raise<F> instead of a Raise<Outcome<F, Nothing>>
  • recover's recover properly instantiates an OutcomeFailure value, thus having a symmetry between block which instantiates a successful variant and recover which instantiates a failure variant

Was there an error in the documentation, or did I completely misunderstand the given example?


I was originally going to ask this on the Slack channel, but as the message grew I thought it would be better here… Is this the right place to ask such questions?

@kyay10
Copy link

kyay10 commented Apr 5, 2023

All the other raise builders in Builders.kt seem to be using the error types directly, only exception being Option which uses the singleton None instance. Maybe the documentation needs updating? I see why the docs are the way they are because of that Loading singleton because you'd be raising a Nothing value if you didn't raise error wrappers. I think that choosing just the error type itself, or a wrapped error type, depends heavily on what you're planning to do with it. In your case, definitely having Raise<F> is the way to go.

@nomisRev
Copy link
Member

nomisRev commented Apr 6, 2023

Hey @CLOVIS-AI,

What @kyay10 mentions is exactly right. I meant to update the example from the documentation to use the example from Quiver, because Lce can also benefit from that same encoding but it's a bit more complex / comes with some trade-offs. See Raise Builder Quiver.

So the last snippet you shared is perfect! inline fun <F : Failure, T> out(@BuilderInference block: OutcomeDsl<F>.() -> T) : Outcome<F, T>.

Was there an error in the documentation, or did I completely misunderstand the given example?

The reason I choose Lce<E, Nothing> was actually because I had in mind a DSL that have multiple errors, but even there you'd benefit of designing your ADT slightly different. Let's take this example:

DialogResult<out T>
 |- Positive<out T>(value: T) : DialogResult<T>
 |- Neutral : DialogResult<Nothing>
 |- Negative : DialogResult<Nothing>
 \- Cancelled: DialogResult<Nothing>

We can now not really conveniently provide Raise over a flat type, and are kind-of forced to use DialogResult<Nothing>, but if we instead would design the ADT slightly different:

DialogResult<out T>
 |- Positive<out T>(value: T) : DialogResult<T>
 \- Error : DialogResult<Nothing>
      |- Neutral : Error
      |- Negative : Error
      \- Cancelled: Error

We can again benefit from Raise<DialogResult.Error>, and the reason that this is much more desirable, it that you can now also interop with Either!

dialogResult {
  val x: DialogResult.Positive(1).bind()
  val y: Int = DialogResult.Error.left().bind()
  x + y
}

That can be useful if you need to for example want to accumulate errors, you can now benefit from the default behavior in Kotlin.

fun dialog(int: Int): DialogResult<Int> =
  if(int % 2 == 0) DialogResult.Positive(it) else Dialog.Neutral

val res: Either<NonEmptList<DialogResult.Error>, NonEmptyList<Int>> =
  listOf(1, 2, 3).mapOrAccumulate { i: Int ->
    dialog(it).getOrElse { raise(it) }
  }

dialogResult {
  res.mapLeft { ... }.bind()
}

But I went a bit of topic. The initial documentation was kind-of brief, but I was hoping for feedback like this, so thank you @CLOVIS-AI! I'm looking for feedback to improve this section in the documentation.

Hopefully soon your example will be simplified to:

context(Raise<F>)
fun <F : Failure, T> Outcome<F, T>.bind(): T = when (this) {
    is OutcomeSuccess -> value
    is OutcomeFailure -> raise.raise(failure)
}

@OptIn(ExperimentalTypeInference::class)
inline fun <F : Failure, T> out(@BuilderInference block: OutcomeDsl<F>.() -> T) : Outcome<F, T> =
    recover(
        block = { OutcomeSuccess(block(OutcomeDsl(this))) },
        recover = { e: F -> OutcomeFailure(e) },
    )

And more complex ADTs could consist of any different numbers of Raise, for example the one in Quiver:

sealed class Outcome<out E, out A> constructor(val inner: Either<E, Option<A>>) {
  data class Present<A>(val value: A) : Outcome<Nothing, A>(value.some().right())
  data class Failure<E>(val error: E) : Outcome<E, Nothing>(error.left())
  object Absent : Outcome<Nothing, Nothing>(None.right())
}

context(Raise<None>, Raise<E>)
fun <E, A> Outcome<E, A>.bind(): A = when(this) {
  Absent -> option.raise(None)
  is Failure -> raise(error)
  is Present -> value
}

inline fun <E, A> outcome(block: context(Raise<None>, Raise<E>) () -> A): Outcome<E, A> =
 either {
   option { block() }
 }.toOutcome()

That'll allow much more flexibility for more advanced ADTs, and different intersection types to combine Raise in different ways to cover the error-cases.

I hope that helps, and looking forward on how you think we can improve the documentation. Perhaps if warranted we can turn it into its own page so that we can have a more extended explanation with more details.

PS: This is the perfect place for such questions, and feedbacks in the documentation. If you find anything missing, or lacking please open a ticket so we can work together on improving the website and documentation further! We at this point don't consider it complete but rather 1.x.x ☺️

@CLOVIS-AI
Copy link
Contributor Author

Thanks a lot for your answer, it does clear everything up. The ability to create custom DSLs is really amazing, I'm going through all my projects to upgrade them to this new style.

I'm not sure what your issue workflow is here, but as far as I'm concerned, all my questions have been answered, so feel free to close this :)

@nomisRev
Copy link
Member

nomisRev commented Apr 9, 2023

@CLOVIS-AI I’d like to keep this open until we fix this.

I hope that helps, and looking forward on how you think we can improve the documentation. Perhaps if warranted we can turn it into its own page so that we can have a more extended explanation with more details.

This issue kind-of represents a short-coming in the docs, so I’d like to improve it.

serras added a commit that referenced this issue May 6, 2023
This PR introduces a few improvements to the _typed errors_ section,
based on discussions I had with attendees to KotlinConf'23.
- Separates the _Create your own errors_ into its own page, and includes
the discussion in #161.
- Makes it more clear what we mean with each term (logical failure,
success...).
- More slowly builds the differences between Either/Result/Raise...

---------

Co-authored-by: Simon Vergauwen <nomisRev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants