https://kotlinlang.org logo
Title
c

CLOVIS

03/28/2023, 7:58 AM
How do you handle validation in the primary constructor? In vanilla Kotlin, it's idiomatic to write
value class Password(val text: String) {
    init {
        require(text.length > 8) { "A password should have at least 8 characters, found ${text.length}" }
    }   
}
I know about
ensure
& co, however the primary constructor cannot return
Either
, so how do you recommend exposing validation? I guess another pattern could be to provide a factory method?
fun String.asPassword() = Either.catch { Password(this@asPassword) }
s

simon.vergauwen

03/28/2023, 8:09 AM
Currently it's not possible in the primary constructor 😭 Problem with using
require
is that it fails fast, but so does your other example using
context
. Otherwise you would need
Raise<NonEmptyList<PasswordError>>
or
PasswordValidationError
that has all errors inside. If that is not a concern, then using
require
and
Either.catch
is a good solution.
m

Marko Novakovic

03/28/2023, 9:07 AM
I would go with factory
You can make function the same name as the
value class
so it looks like you are calling the constructor
s

simon.vergauwen

03/28/2023, 9:08 AM
@Marko Novakovic that is of course also a good option, but I think @CLOVIS was looking specifically for options in combination with primary constructor. Side-note: if used with KotlinX, there is no way to use it with factory methods instead of constructor. Right? 🤔
m

Marko Novakovic

03/28/2023, 9:09 AM
oh I see, my bad
if used with KotlinX, there is no way to use it with factory methods instead of constructor.
can you elaborate a bit more? I don’t understand the issue there
s

simon.vergauwen

03/28/2023, 9:14 AM
If you annotate a type with
@Serializable
it will use primary constructor, not factory methods.
m

Marko Novakovic

03/28/2023, 9:14 AM
oooh yes, correct
c

CLOVIS

03/28/2023, 9:26 AM
so it looks like you are calling the constructor
But then it's unclear whether you're calling the overload that throws or the overload that raises.
m

Marko Novakovic

03/28/2023, 9:26 AM
in case you already have said function than I would choose better naming approach
maybe clear function names inside
companion object
or something
c

CLOVIS

03/28/2023, 9:27 AM
Then that's just the example I gave, isn't it?
m

Marko Novakovic

03/28/2023, 9:28 AM
yes. except that I don’t like creating side effects so I would advise against exact code you posted
but if you are combining it with
Either.catch
that that’s fine imo
c

CLOVIS

03/28/2023, 9:34 AM
Or maybe the opposite? 🤔
sealed class PasswordValidatorError { … }

value class Password(val text: String) {
    init {
        validate(text).orElse { throw IllegalArgumentException(it.toString()) }
    }

    companion object {
        fun validate(text: String) = either {
            ensure(text.length > 8) { PasswordValidatorError.TooShort }
        }
    }
}

fun String.asPassword() = either {
    validate(this).bind()
    Password(this)
}
It's more verbose, and the validation is always executed twice, but it doesn't use exceptions for error management
m

Marko Novakovic

03/28/2023, 9:43 AM
but it will throw if it’s too short. no?
it’s too verbose. I don’t know of the actual usecase but this is too verbose for such a simple thing
c

CLOVIS

03/28/2023, 9:44 AM
Well the use case is just to provide Arrow-based validation for value/data classes.
There's no doubt that validation should be in the primary constructor, but it can't return Either.
m

Marko Novakovic

03/28/2023, 9:45 AM
yes that’s really a bummer
your 1st approach seems better on of the two, imo
p

phldavies

03/28/2023, 1:10 PM
In the past I've used a private constructor and companion object factories
create(text: String): Validated<A, B>
and
createUnsafe(text: String): B = create(text).getOrThrow()
And used a custom serializer in kotlinx to handle validation and propagation
s

simon.vergauwen

03/28/2023, 1:24 PM
And used a custom serializer in kotlinx to handle validation and propagation
Do you have an example of this?
p

phldavies

03/28/2023, 3:07 PM
Unfortunately I no longer have access to that codebase, but I believe it was just a case of calling the validated factory method in a custom serializer and lifting the validation error into a
SerializationException
subclass (so it plays nicely with the rest of kotlinx-serialization)
s

simon.vergauwen

03/28/2023, 3:10 PM
Thanks, that makes sense. That would indeed be the best option to get best of both worlds. I think no1 is interested in another serialiser library when KotlinX can plug-n-play to different formats 😂
c

CLOVIS

04/15/2023, 2:43 PM
The latest evolution of my answer to this question, assuming context receivers:
// Utilities

fun requireSuccess(block: Raise<*, *>.() -> Unit) {
    val value = either { block() }
    require(value is Either.Right) { (value as Either.Left).value.toString() }
}

// Actual code

data class User(
    val username: String,
    val age: Int,
) {
    init {
        requireSuccess(validateUsername(username))
        requireSuccess(validateAge(age))
    }

    companion object {
        context(Raise<ValidationError>)
        fun validateUsername(username: String) {
            ensure(username.length > 4) { ValidatorError.UsernameTooShort }
            ensure(username.length < 100) { ValidationError.UsernameTooLong }
        }

        context(Raise<ValidationError>)
        fun validateAge(age: Int) {
            ensure(age >= 13) { ValidationError.TooYoung }
        }
    }

    sealed class ValidationError { … }
}

context(Raise<ValidationError>)
fun user(username: String, age: Int): User {
    validateUsername(username)
    validateAge(age)
    return User(username, age)
}
Pros: • The constructor throws IllegalArgumentException (idiomatic) • The factory requires a Raise context and raises a custom sealed class (idiomatic) • UI code can validate values independently in forms Cons: • The validation is executed twice when using the factory (should be fine?)