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

[DISCUSSION] Exact Core 0.0.1 #8

Merged
merged 12 commits into from
May 8, 2023

Conversation

ILIYANGERMANOV
Copy link
Collaborator

@ILIYANGERMANOV ILIYANGERMANOV commented May 6, 2023

Thank you @ustits for the awesome idea to drop the entire builder and just use a simple Raise<E>.f(A) -> R function!
Based on our discussions in #4 , #6 and #7. This is how I imagine the entire Exact Core to look like.

  • core = just providing the capabilities to build Exacts and Refined types
  • common = a collection of built-in Refined types like PositiveInt, NotBlankTrimmedString, etc

This PR is raised for discussion purposes and if we happen to like the ideas in it - we can merge it and build on top of it.
As always I'm more than open to suggestions and any changes - big or small! 👍

Can't wait to use our small Arrow Exact library in my personal projects! 🚀

@ILIYANGERMANOV
Copy link
Collaborator Author

Demo

@JvmInline
value class NotBlankString private constructor(
  override val value: String
) : Refined<String> {
  companion object : Exact<String, NotBlankString> by exact({
    ensure(it.isNotBlank()) { ExactError("Cannot be blank.") }
    NotBlankString(it)
  })
}

@JvmInline
value class NotBlankTrimmedString private constructor(
  override val value: String
) : Refined<String> {
  companion object : Exact<String, NotBlankTrimmedString> by exact({ raw ->
    val notBlank = NotBlankString.from(raw).bind()
    NotBlankTrimmedString(notBlank.value.trim())
  })
}

sealed interface UsernameError {
  object Invalid : UsernameError
  data class Offensive(val username: String) : UsernameError
}

@JvmInline
value class Username private constructor(
  override val value: String
) : Refined<String> {
  companion object : ExactEither<UsernameError, String, Username> by exactEither({ rawUsername ->
    val username = NotBlankTrimmedString.from(rawUsername)
      .mapLeft { UsernameError.Invalid }.bind().value
    ensure(username.length < 100) { UsernameError.Invalid }
    ensure(username !in listOf("offensive")) { UsernameError.Offensive(username) }
    Username(username)
  })
}

@JvmInline
value class PositiveInt private constructor(
  override val value: Int
) : Refined<Int> {
  companion object : Exact<Int, PositiveInt> by exact({
    ensure(it > 0) { ExactError("Must be positive.") }
    PositiveInt(it)
  })
}

I don't know about you but creating Exact/Refined types is fun! I think we're going in the right direction :) I'm wondering if there's more utility that we'd like to add?

}

// TODO: What are your thoughts about this?
operator fun invoke(value: A): R = fromOrThrow(value)
Copy link
Collaborator Author

@ILIYANGERMANOV ILIYANGERMANOV May 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit controversial. What do you think?
My opinion is that there are many cases where you're sure that something matches the constraints and going through the hassle of dealing with an Either is unnecessary.

// here I'm certain that positive + positive is always a positive
fun plus(x: PositiveInt, y: PositiveInt): PositiveInt = PositiveInt(x.value + y.value)

fun plusUgly(x: PositiveInt, y: PositiveInt): PositiveInt = PositiveInt.fromOrThrow(x.value + y.value)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally think orThrow is concise enough it's worth the readability. Kotlin favours explicitness in a lot of areas, and this seems a place that is a good trade-off for it as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's true @nomisRev . IMO, it just becomes too verbose, distracting and not right making the possibility of throwing an exception for cases where that's obviously impossible like:

val hello = NotBlankTrimmedString.fromOrThrow("Hello")
val world = NotBlankTrimmedString.fromOrThrow("wold!")
val helloWorld = NotBlankTrimmedString.fromOrThrow(hello.value + " " + world.value)

Personal opinion, the above is too much noise and I prefer the below:

val hello = NotBlankTrimmedString("Hello")
val world = NotBlankTrimmedString("wold!")
val helloWorld = NotBlankTrimmedString(hello.value + " " + world.value)

Note these two are artificial examples but in practice we have many places where we're certain that a constraint won't be violated, in my personal projects I reserve the operator fun invoke(value: A): R = fromOrThrow(value) for such cases.

When using for example PositiveInt(x + y) instead of PositiveInt.fromOrThrow(x + y) we show our readers that we're certain that in the first case x + y will always be positive but in the 2nd we direct their attention to knowing that it's not always the case.

I suggest leaving it for discussion and re-visiting before releasing the library.

@ustitc
Copy link
Collaborator

ustitc commented May 6, 2023

Looks fantastic 🚀

I also want to explore these topics

  1. custom ensure for Exact like
ensure(it, NotBlankString) {
  UsernameError.Invalid
}

It will make chaining of Exacts easier

  1. Exacts composition. For example it would be cool to have
class UserName {
  companion object : NotBlankString and StringWithLengthLt(16)
}

It will also give us room to ship predefined Arrow Exacts and for communities to build their own and share. I had the same idea in krefty with Predicates but predicate alone turned out to be useless without type conversion and errors

  1. Can we make a declaration of Exact/ExactEither more compact?

Will make one more round later but already looks as a good candidate for 0.0.1

@ILIYANGERMANOV
Copy link
Collaborator Author

Looks fantastic 🚀

I also want to explore these topics

  1. custom ensure for Exact like
ensure(it, NotBlankString) {
  UsernameError.Invalid
}

It will make chaining of Exacts easier

  1. Exacts composition. For example it would be cool to have
class UserName {
  companion object : NotBlankString and StringWithLengthLt(16)
}

It will also give us room to ship predefined Arrow Exacts and for communities to build their own and share. I had the same idea in krefty with Predicates but predicate alone turned out to be useless without type conversion and errors

  1. Can we make a declaration of Exact/ExactEither more compact?

Will make one more round later but already looks as a good candidate for 0.0.1

+1 for all topics! Let's explore them 🚀

  1. Looks doable and awesome for predicates! The downside is that with this type of ensure we lose all transformations - e.g. won't work with NotBlankTrimmedString.
ensure(it, NotBlankString) { UsernameError.Invalid }
  1. Yes, we need a good way of composing Exacts w/o losing the transformations (type conversions)
  • Feel free to suggest designs on it.
  1. I haven't explored much but will gladly welcome any suggestions for reducing the boilerplate! So far, that's the best I can get to compile 😀

@ustits I'm wondering should we merge this PR so we can build 1) and 2) on top of it? Alternatively, each of us can play on a different versions of Exact and in the end we'll pick the best from both variants. I'm okay with both approaches 👍

import kotlin.jvm.JvmInline
import kotlin.random.Random

// TODO: We need a lint check telling people to make their constructors private
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make an issue to make a small detekt integration module for this?

Copy link
Member

@nomisRev nomisRev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks great 👏 👏 Great discussion and improvements overall ☺️

Looks doable and awesome for predicates! The downside is that with this type of ensure we lose all transformations - e.g. won't work with NotBlankTrimmedString.
ensure(it, NotBlankString) { UsernameError.Invalid }

Hmm, I'm not sure about that.. We could have NotBlankString as a sort of filter map. So something like.

import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensureNotNull

fun interface FilterMap<A : Any, B : Any> {
  operator fun invoke(a: A): B?
}

fun <E, A : Any, B : Any> Raise<E>.ensure(
  a: A,
  filterMap: FilterMap<A, B>,
  error: (A) -> E
): B {
  val b = filterMap(a)
  return ensureNotNull(b) { error(a) }
}

val NotBlankString: FilterMap<String, String> =
  FilterMap { it.ifBlank { null } }

object Invalid

val x: Either<Invalid, String> = either {
  ensure("hello", NotBlankString) { Invalid }
}

So by constraining A and B to non-null we can avoid needing Option, and we can use null as a false signal for the predicate. I choose FilterMap as a name here, but that's perhaps not a great name. MapNotNull might be more in line with Kotlin Std. It offers a similar pattern for Iterable.

I really like the idea of being able to compose, expose/share predicates, or refinements like this as @ustits did in krefty.

We can probably remove Refined, or replace it with a typealias Refined<A> = Either<ExactError, A> and see how it plays out.

I am okay merging this PR, and doing follow ups. That would be easier for discussing further I think. Again really awesome @ustits and @ILIYANGERMANOV ❤️

This week I am going to setup all the configuration for publishing this library, so we can publish 0.0.1 whenever we think is a good moment 🥳

@ustitc
Copy link
Collaborator

ustitc commented May 8, 2023

@nomisRev

I really like the idea of being able to compose, expose/share predicates, or refinements like this as @ustits did in krefty.

We can borrow some ideas from Clojure community, they had much more time refining them in specs https://clojure.org/guides/spec#_composing_predicates

So actually if I think about this.. Is Refined not the same as Either<ExactError, A>? 🤔 And do we not get all required functions through that?
We can probably remove Refined, or replace it with a typealias Refined = Either<ExactError, A> and see how it plays out.

Came up with the same thought after changing an api of krefty this week, Refinement (renamed it from Refined) became a monad with almost the same functionality as Either

refine(NotBlank(), "Krefty")
    .map { NotBlankString(it) }
    .flatMap { refine(UserNamePredicate(), it) }

This week I am going to setup all the configuration for publishing this library, so we can publish 0.0.1 whenever we think is a good moment 🥳

Thanks! 🚀

@ILIYANGERMANOV ILIYANGERMANOV merged commit 371c5f9 into arrow-kt:main May 8, 2023
@ILIYANGERMANOV
Copy link
Collaborator Author

Thank you both @ustits and @nomisRev! We're making awesome progress on this library! You suggested great points, let's explore them 🚀 I merged this PR for the sake of easier discussions and building on top of it.

Changes:

  • Refined<A> removed

TODO:

@nomisRev
Copy link
Member

nomisRev commented May 8, 2023

Came up with the same thought after changing an api of krefty this week, Refinement (renamed it from Refined) became a monad with almost the same functionality as Either

Is there a commit you can share? Would love to see how you implemented this?

We can borrow some ideas from Clojure community, they had much more time refining them in specs https://clojure.org/guides/spec#_composing_predicates

Interesting, I like Clojure and hadn't thought of this for inspiration. I've always thought of Scala refined libraries, but the Clojure docs you shared have a lot of great ideas!

@ustitc
Copy link
Collaborator

ustitc commented May 8, 2023

@nomisRev you can check it by this commit where I added map and flatMap ustitc/krefty@ac67a74#diff-78a62f4e2244be79cf81a394e044b8971ac9ddd195afd6add4ef31d4c3e2f830

Also has a local commit which introduces filter so Refinement can be used like this

refine("Krefty")
    .filter(NotBlank())
    .filter { it.length > 3 }
    .map { NotBlankString(it) }
    .flatMap { refine(UserNamePredicate(), it) }

@nomisRev
Copy link
Member

nomisRev commented May 8, 2023

Looking at this I'd love to have some Raise based Refinement DSL. Looking at what you've shown above it could probably be made available through a DSL that looks something like this.

val Either<NoExact, NotBlankString> =
  refine("Krefty") {
    notBlank() // refine(NotBlank)
    ensure(it.length > 3)
    refine(UserNamePredicate)
    NotBlankString(it)
  } // getOrElse, getOrNull, bind, etc.

refine methods would also return a val x: String in this case, but since it's refinements you can just go top-down and we an use Kotlin Contracts as well within the DSL. Although Kotlin Contracts are still limited, I expect a bit more powerful support in K2 and hopefully more powerful during Kotlin 2.x

@ILIYANGERMANOV
Copy link
Collaborator Author

Looking at this I'd love to have some Raise based Refinement DSL. Looking at what you've shown above it could probably be made available through a DSL that looks something like this.

refine("Krefty") {
  notBlank() // refine(NotBlank)
  ensure(it.length > 3)
  refine(UserNamePredicate)
  NotBlankString(it)
}

@nomisRev, that looks awesome! +1

@nomisRev
Copy link
Member

nomisRev commented May 8, 2023

I guess this is already close to what we have with exact, but perhaps we can do something like.

interface ExactScope<A> : Raise<ExactError> {
  val raw: A
  
  fun refine(refine: FilterMap<A>): A
  
  // ...
}

val NotBlank: FilterMap<String> =
  FilterMap { it.ifBlank { null } }

fun ExactScope<String>.notBlank(): String = refine(NotBlank)

PS: I don't like the FilterMap name 😂

@ILIYANGERMANOV
Copy link
Collaborator Author

ILIYANGERMANOV commented May 8, 2023

I guess this is already close to what we have with exact, but perhaps we can do something like.

interface ExactScope<A> : Raise<ExactError> {
  val raw: A
  
  fun refine(refine: FilterMap<A>): A
  
  // ...
}

val NotBlank: FilterMap<String> =
  FilterMap { it.ifBlank { null } }

fun ExactScope<String>.notBlank(): String = refine(NotBlank)

PS: I don't like the FilterMap name joy

Awesome! @nomisRev can you submit a PR with it? I like it a lot!
Name suggestion: FilterMap -> Refinement (it's basically a filter + map = mapNotNull, as you said and named it but IMO we'll confuse fewer people if we call it Refinement<A> and provide KDoc. At the end of the day, we'll spam refine(XXX) in the ExactScope.

I'm excited to add arrow-exact-0.0.x to Ivy Wallet 🚀 , IMO, if we can do the improvements discussed, it'll become usable and after testing it internally we'll gather more ideas and feedback.

@nomisRev
Copy link
Member

nomisRev commented May 8, 2023

I can do a PR for that this week.

@ILIYANGERMANOV ILIYANGERMANOV mentioned this pull request May 14, 2023
@renovate renovate bot mentioned this pull request Feb 29, 2024
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants