-
Notifications
You must be signed in to change notification settings - Fork 4
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] High-level scope and idea of Arrow Exact #4
Comments
I'm also thinking about a Here's a quick and ugly prototype of it: interface Constraints {
val name: String
val requirement: String
fun apply(text: String): Option<String>
}
@JvmInline
value class Text<C : Constraints> private constructor(
private val value: String,
) {
companion object {
fun <C : Constraints> fromString(text: String, constraints: C): Option<Text<C>> =
constraints.apply(text).map(::Text)
fun <C : Constraints> unsafe(
text: String,
constraints: C,
tag: String? = null
): Text<C> = fromString(text, constraints).fold(
ifEmpty = {
throw ArrowExactTextConstraintError(
constraints = constraints,
violatingValue = text,
tag = tag,
)
},
ifSome = ::identity
)
operator fun <C : Constraints> invoke(text: String, constraints: C): Text<C> =
unsafe(text, constraints)
}
}
class ArrowExactTextConstraintError(
constraints: Constraints,
violatingValue: String,
tag: String?
) : ArrowExactConstraintViolationError(
constraint = constraints.name,
requirement = constraints.requirement,
violatingValue = violatingValue,
tag = tag,
)
object Email : Constraints {
override val name: String
get() = "Email"
override val requirement: String
get() = "has @ and is trimmed"
override fun apply(text: String): Option<String> = if ('@' in text) Some(text.trim()) else None
}
object PersonName : Constraints {
override val name: String
get() = "PersonName"
override val requirement: String
get() = "not blank and trimmed"
override fun apply(text: String): Option<String> {
return text.trim().takeIf { it.isNotBlank() }?.some() ?: None
}
}
data class User(
val email: Text<Email>,
val firstName: Text<PersonName>,
val lastName: Text<PersonName>,
)
fun main() {
val user = User(
email = Text("iliyan.germanov971@gmail.com", Email),
firstName = Text("Iliyan ", PersonName),
lastName = Text("Germanov", PersonName)
)
println(user)
}
|
Looks interesting. I wonder how well it generalizes to any type (all your examples are for
Maybe the library should be split into two modules, one that provides the utilities to set everything up (e.g. your |
Good point @CLOVIS-AI! I agree with you - tried generalizing the Constrained APIinterface Constraint<A> {
val name: String
get() = this::class.simpleName ?: ""
val requirement: String
fun apply(value: A): Option<A>
}
open class Constrained<A, Cons : Constraint<A>>(
val value: A,
) {
companion object {
fun <A, Cons : Constraint<A>> fromValue(
value: A, constraint: Cons
): Option<Constrained<A, Cons>> =
constraint.apply(value).map(::Constrained)
fun <A : Any, Cons : Constraint<A>> unsafe(
value: A,
constraint: Cons,
tag: String? = null
): Constrained<A, Cons> = fromValue(value, constraint).fold(
ifEmpty = {
throw ArrowExactConstraintViolationError(
constraint = constraint.name,
requirement = constraint.requirement,
violatingValue = value,
tag = tag,
)
},
ifSome = ::identity
)
operator fun <A : Any, Cons : Constraint<A>> invoke(
value: A, constraint: Cons
): Constrained<A, Cons> = unsafe(value, constraint)
}
}
// This is bad! Needs improvement but sharing just for the sake of discussion
fun <A : Any, Cons : Constraint<A>> Constrained<A, Cons>.fmap(
constraint: Cons,
f: (A) -> (A)
): Option<Constrained<A, Cons>> = Constrained.fromValue(f(value), constraint) Demoobject PositiveIntCons : Constraint<Int> {
override val requirement = "positive, > 0"
override fun apply(value: Int): Option<Int> {
return if (value > 0) Some(value) else None
}
}
typealias PositiveInt = Constrained<Int, PositiveIntCons>
object NotBlankTrimmedStringCons : Constraint<String> {
override val requirement = "not blank after trim"
override fun apply(value: String): Option<String> {
return value.trim().takeIf { it.isNotBlank() }?.some() ?: None
}
}
class NotBlankTrimmedString(value: String) : Constrained<String, NotBlankTrimmedStringCons>(value)
object CitizenIdCons : Constraint<Int> {
override val requirement = "divisible by 2"
override fun apply(value: Int): Option<Int> {
return if (value % 2 == 0) Some(value) else None
}
}
class CitizenId(value: Int) : Constrained<Int, CitizenIdCons>(value)
data class User(
val id: CitizenId,
val name: NotBlankTrimmedString,
val age: PositiveInt,
)
fun demo() {
val user = User(
id = CitizenId(4), //PositiveInt(4) won't work which is good
name = NotBlankTrimmedString("Iliyan"),
age = PositiveInt(26),
)
} |
I see as a goal a "type-safe"(?) arithmetic, where e.g. division by zero would raise a typed error instead of throwing an exception, and integer overflow would be an error instead of silently overflowing. |
Yes, that should be one of its goals but IMO it should be way more than just arithmetic. Following this FP principle:
The stricter we define our types => the less validation and cases our domain logic would have to handle. Using only Arrow Core, the
Example: data class Player(
val alias: NotBlankTrimmedString, // let's say we don't care about the max length
val username: Username, // constrained to [a-z][0-9]_
val winSteak: NonNegativeInt,
val level: PlayerLevel, // constrained in [1,120]
val winRate: Percent,
) We can say that the 💡 [just for brainstorming] Many times this I tried defining my data model in Ivy Wallet following this principle... And ended up needing to manually create a lot of primitives like |
We could have We could also look at https://github.com/ustits/krefty for some prior art. |
Defining intersecting constraints will be tough, though. There are some (technically unsupported) compiler tricks to create intersection types from thin air, but I don't know if that's the most apt thing here. |
Is there a reason to use Option over kotlins nullability system? Using the nullability of Kotlin makes sure we do not need a dependency on Arrow-Core for now. |
Thank you for joining the discussion @vandeven!
In reality, Ofc, we can also choose Example: // With Option we can use the entire Arrow Raise API and all the goodides
context(Raise<String>)
fun optionDemo() = option {
val a = PositiveDouble.fromDouble(0.5).bind()
val b = PositiveDouble.fromDouble(-5.0).bind()
ensure(a > b)
} Note: I am biased because I am full on FP. Yes can achieve the above things with
Yes, you totally can. However, if you want really "exact" and precise data models you'll need a lot of those value class wrappers. I implemented a lot of those for my Ivy Wallet project: But still they're not enough and I haven't implemented all the FP extensions that I need. Definitely, I'd need something like the For some types it would be nice if we provide operators overloading (e.g. +, -, *, /), docs describing the edge cases (e.g. Double.MAX_VALUE + 1, Double.MAX_VALUE * 2, Double.POSITIVE_INFINITY). There are many common values classes that can also be built-in like Also, if we provide a good API for "refining" / constraining new types it would be nice. |
From the arrow docs:
There is no nested nullability or other libraries here. Also, fun getMyDouble(): Either<ExactError, PositiveDouble> = either {
val a = PositiveDouble.fromDouble(0.5)
val b = PositiveDouble.fromDouble(-5.0)
a + b
} |
|
I would also agree on an Either with full error information. |
@vandeven Ofc, any contribution is more than welcome! Feel free submit PRs / comments with prototypes, ideas - everything is welcome! :) And much appreciated! |
It has to be a running joke at this point that all Arrow projects start by complaining that type classes are not a language feature 😓 I'm a bit worried about library size and width, especially when creating those types yourself is not that hard. Plus, most use cases I can think of for this library are domain-specific (e.g. I have an To ensure we are talking about the same thing, here is an example of what my codebase currently looks like (assuming context receivers): value class Password(val text: String) {
init {
require(text.length < Failures.TooShort.minLength) { Failures.TooShort.toString() }
require(text !in Failures.TooSimple.blacklist) { Failures.TooSimple.toString() }
}
sealed class Failures {
object TooShort : Failures {
val minLength = 8
override fun toString() = "A password must be at least $minLength characters long"
}
object TooSimple : Failures {
val blacklist = listOf("123456", "123456789")
override fun toString() = "The provided password is too simple"
}
}
}
context(Raise<Password.Failures>)
fun passwordOf(text: String): Password {
ensure(text.length < Password.Failures.TooShort.minLength) { Failures.TooShort }
ensure(text !in Password.Failures.TooSimple.blacklist) { Failures.TooSimple }
return Password(text)
} My primary goal here is to reduce the boilerplate to declare these kinds of types. What do you think? @ILIYANGERMANOV Can you rewrite this example in your envisioned way to do things, to compare? |
@CLOVIS-AI I have a similar use-case: constrained domain types validated by business rules (e.g. I don't know or have a strong opinion how the final design should look like but I want the following:
Your Code generation (KSP)Definition @ArrowExactType(name = "Password")
object PasswordDefiniton {
context(Raise<Password.Failure>)
fun constrain(value: String): String {
ensure(text.length > MIN_LENGTH) { Failure.TooShort }
ensure(text !in blacklist) { Failure.TooSimple }
// add any transformations that you might want to do
return value
}
} Generated // Generated by KSP
@JvmInline
value class Password private constructor(val value: String) {
companion object {
context(Raise<ArrowExactError<Password.Failure>>)
fun from(val value: String): Password {
// ...
}
fun unsafe(value: String): Password = recover(::from) {
throw ArrowExactRuntimeError(...)
}
operator fun invoke(value: String): Password = unsafe(value)
}
}
context(Raise<ArrowExactError<Password.Failure>>)
fun Password.fmap(f: (String) -> String): Password Usage: val password = Password("123abcd!") // unsafe, runtime err - use only when certain
recover({
Password.from(input) // Raise API, safe
}) { error -> // ArrowExactError<Password.Failure>
when(error.val) {
Password.Failure.TooShort -> // ...
// ...
}
} How do you imagine it to work? What do you think? P.S. This is a pseudo-code written at 1am from my phone for the purpose of illustration and discussion. Don't take it literally - please share your honest thoughts, crazy ideas or any use-cases that you want this library to solve for you :) |
Hey all, Sorry for the late (and short) reply. After KotlinConf I am again travelling for work, and had little time to invest into this. It's a quite interesting discussion above here
IMO,
I think exposing multiple signatures would be ideal, and potentially we can support I think I personally am very interested in Krefy it looks quite nice, and it allows composing different refinements and predicate 🤔 Sadly it seems a development has stopped on the library. |
@nomisRev Indeed, Krefy looks quite good. Using it in libraries will be hard though, because it must be part of the public API (because of inheritance). It also (probably?) stops you from implementing abstract classes (though I guess our idea with @ILIYANGERMANOV I'm not a fan of KSP. It's extremely hard to debug for downstream users when it goes wrong, and it often has setup problems with IDEA & multiplatform. |
@CLOVIS-AI yeah totally, I'm still struggling to make Arrow Optics' KSP work on my multiplatform project. So definitely, we should avoid it. @nomisRev good points 👍 I agree with them and indeed Only the infix extension I haven't look at |
It would be nice if we can invite Ustits, Krefty's creator to collaborate on this. As a user, I have a big need for such a library because we practice DDD, and constraining our types is a boilerplate we write in every project. |
I can invite @Ustits, I'm. not sure if he's still interested on working on this or what his thoughts are on Krefty's.
I'm not sure, how do you currently deal with this? You need to re-check the properties on
I think the idea is that we can compose functions, or "type-level operations" into a new type. Ideally, we could do something like. value class NotBlankTrimmedString private constructor(val value: String) {
companion object : Constrain<All<NotBlank, Trimmed>, String, NotBlankTrimmedString>
} Where This is what I have in my mind, but I'm not sure how we can currently implement such a thing. I think it's possible but I haven't been able to dig my hands into it since I'm quite overloaded 😵 |
Yes, every mapping may break the Invariant and recheck is required. What I do is simply first @nomisRev your proposals look good to me! 👍 I like the Regarding the implementation of
It might be possible but I don't know exactly how we can do that. IMO, worst case we can use KSP. With KSP we'll have the freedom to generate tons of helpful stuff w/o limitations. The downside is that it'll make the library integration pain in the ass. |
Hi all, very pleased that my refinement experiments in @nomisRev I like the idea of companion object and that the scope of
That is a good point, probably One more possible design: abstract class Exact<T, R>(
private val constraint: Constraint<T>,
private val constructor: (T) -> R
) {
fun create(value: T): R {}
fun createOrNull(): R? {}
}
value class NotBlankTrimmedString private constructor(val value: String) {
companion object : Exact<String, NotBlankTrimmedString>(
constraint = NotBlank() and Trimmed(),
constructor = { NotBlankTrimmedString(it) }
)
} Don't like the notion of |
Awesome! @ustits glad to hear that you're interested in collaborating on this :) Whoever has some free time and enthusiasm can start. I vote for the last proposed design by @ustits: interface Constraint<T, R> {
fun constrain(value: T): R // <-- can be a transformation (mapping), a predicate or both
}
abstract class Exact<T, R>(
private val constraint: Constraint<T>,
private val constructor: (T) -> R
) {
fun create(value: T): R {}
fun createOrNull(): R? {}
}
value class NotBlankTrimmedString private constructor(val value: String) {
companion object : Exact<String, NotBlankTrimmedString>(
constraint = NotBlank() and Trimmed(),
constructor = ::NotBlankTrimmedString
)
} To recap based on our discussion, we're looking for the following properties:
What are your thoughts? @nomisRev are we ready to start? 🚀 I want it to be fun to use in practice while making strong guarantees about the data model it defines. |
I don't have much free time at the moment, but I completely agree with @ILIYANGERMANOV's comment above. |
I am trying a couple of alternative designs, will create a PR when I would have time |
Hey everyone, created a first draft, feel free to leave comments about the design #6 |
The idea of Arrow Exact is to provide common value class wrappers required for defining precise, type-safe, and explicit data models. Along with those value classes, we'll also need to:
Option
orRaise
ornull
) and unsafe ways for creating them.PositiveDouble
->NonNegativeDouble
)Suggestions for Arrow Exact types:
PositiveDouble
NonNegativeDouble
PositiveInt
NonNegativeInt
NotBlankString
NotBlankTrimmedString
Percent
FiniteDouble
There are many more possible but we should decide which ones makes practical sense to have.
Here's an example implementation of some of them:
https://github.com/Ivy-Apps/ivy-wallet/tree/multiplatform/core/src/commonMain/kotlin/ivy/core/data/primitives
With their respective invariants (properties):
https://github.com/Ivy-Apps/ivy-wallet/tree/multiplatform/core/src/commonTest/kotlin/ivy/core/data/primitives
The text was updated successfully, but these errors were encountered: