konform-kt / konform

Portable validations for Kotlin
https://www.konform.io
MIT License
636 stars 39 forks source link

Question: Smart constructor pattern / composable validations #96

Closed jedesroches closed 2 months ago

jedesroches commented 3 months ago

Hello, and thank you for this nice library. Coming from the FP world, I am trying to use it with the smart constructor pattern and, not having a bind (or flatMap, or andThen, or anything you call (ValidationResult<T1> -> (T1 -> ValidationResult<T2>)) -> ValidationResult<T2>), am having a bit of trouble: I'd be happy to open a PR with a bind implementation, but first I'm thinking maybe I'm using this wrong.

Using Konform, I've managed to build the following inline class, who'se type represents a proof of being valid (I don't want to have Valid<x>'s all over my domain code), which works well enough:

@JvmInline
value class CustomerID private constructor(val value: String) {
    companion object {
        operator fun invoke(input: String): ValidationResult<CustomerID> = Validation {
            pattern("^[A-Z0-9]{32}$") hint "Customer ID <value> does not match `^[A-Z0-9]{32}$`"
        }(CustomerID(input))
    }
}

But then when I have a class that is using the above value class as a member, I cannot compose both validations. Given:

data class CustomerDto(
    val id: String,
    val name: String,
    val whatever: String,
)

data class Customer(
    val id: CustomerID,
    val name: CustomerName,
    val whatever: CustomerWhatever,
)

Going from the first above to the second, I cannot do something like CustomerID(id).andThen { id -> ... } to build the final object. But I have to validate the CustomerID before passing it to the Customer constructor (since the CustomerID type enforces validity of the data it contains), and so I am left with no choice but to validate by hand the different elements, and compose the errors manually.

What is the suggested usage pattern for Konform to have both type-level guarantees of data validity when passing values around and composability of validations to build more complex objects ?

I hope I've managed to explain my question correctly, and I'm looking forward to your answer! Have a great day :smiley:

jedesroches commented 3 months ago

In fact, thinking about it, there isn't even the need for bind (i.e. for ValidationResult to be a Monad), apply would be enough (i.e. for it to be an Applicative). Something along the lines of:

public fun <A, B> ValidationResult<(A) -> B>.apply(x: ValidationResult<A>): ValidationResult<B> =
    when (this) {
        is Invalid -> this
        is Valid -> x.map(value)
    }

Would allow one to compose various value class validations through smart constructors to build validated data classes, as so:

// If Foo and Bar are value classes with validation in their companion object invoke methods
data class FooBar(val foo: Foo, val bar: Bar) {
    // Does this exist somehow in Kotlin ?
    companion object { fun curried(foo: Foo) = { bar: Bar -> FooBar(foo, bar) } }
}

// And then this works:
val validatedFooBar: ValidationResult<FooBar> = Foo("abc").map(FooBar::curry).apply(Bar("cde"))

My kotlin is not good enough to make a DSL-y thing, but one could imagine a syntax like below to be rather nice to work with:

val validatedFooBar: ValidationResult<FooBar> = validateApply<FooBar> {
    Foo("abc")
    Bar("cde")
}
dhoepelman commented 2 months ago

I'm not 100% sure I follow, but I think it would be good enough for ValidationResult to be a functor. Then you can do:

val customer: Customer = ...
val dto: ValidationResult<CustomerDto>= customerValidation(customer).map { it.toDto() }

The current (0.4.0) map doesn't properly work, fixed in 0.5.0. See #105 for when it will be released

To help you: In the JVM world functor is usually implemented by map and monadic bind by flatMap. Monadic unit is usually just a constructor. (Valid(...) in konform). I purposefully have left out flatMap so far since I think it's not suitable to the design of the library, but you can easily implement it as an extension function if you want.

Trying to find these in types and using this nomenclature for issues will make things more clear. You will probably also enjoy Arrow (altough I personally don't think Kotlin is suitable for going so hard on the FP)

dhoepelman commented 2 months ago

Closing this as it's not an issue per se, feel free to reply or open a new issue with a more directed question/problem