DEV Community

Cover image for Stop validating like this - there's a cleaner way
tessoir
tessoir

Posted on

Stop validating like this - there's a cleaner way

You've written this. Everyone has.

fun main() {
    val errors = mutableListOf<String>()

    if (team.members.size !in 1..10) errors.add("team must have between 1 and 10 members")

    team.members.forEachIndexed { index, member ->
        if (member.name.isBlank()) errors.add("members[$index].name is blank")
        if (member.name.length !in 2..50) errors.add("members[$index].name length must be between 2 and 50")
        if (member.role.isBlank()) errors.add("members[$index].role is blank")
        if (member.role !in setOf("admin", "member", "viewer")) errors.add("members[$index].role is invalid")
        if (member.age !in 18..65) errors.add("members[$index].age must be between 18 and 65")
    }

    errors.forEach { println(it) }
}
Enter fullscreen mode Exit fullscreen mode

This is what it looks like with KVerify:

fun main() {
    validateCollecting {
        verify(team::members).sizeRange(1, 10).each { member ->
            verify(member::name).notBlank().lengthRange(2, 50)
            verify(member::role).notBlank().oneOf(setOf("admin", "member", "viewer"))
            verify(member::age).between(18, 65)
        }
    }.violations.forEach { println(it) }
}
Enter fullscreen mode Exit fullscreen mode

Interested? Read on.


What is KVerify?

KVerify is a Kotlin Multiplatform validation library. Rules are typed extension functions on Verification<T>. Violations are objects, not strings. Paths track automatically. Collecting all violations or throwing on the first one is a one-word swap.

KMP - JVM, Android, iOS, JS, Wasm, Linux, Windows, macOS.


Violations

Every failed rule produces a Violation. The simplest one is just a reason string:

violation("Username is blank")
Enter fullscreen mode Exit fullscreen mode

But violations can be typed objects carrying whatever data you need - more on that below.


Typed rules

Rules are extension functions on Verification<T>. The compiler enforces what applies where. The IDE shows exactly what's available for the type you're validating.

verify(user::username).notBlank().lengthRange(3, 20)  // ✅
verify(user::age).atLeast(0)                          // ✅
verify(user::age).notBlank()                          // ❌ won't compile
Enter fullscreen mode Exit fullscreen mode

Custom rules

This is what notBlank looks like under the hood:

fun Verification<String>.notBlank(
    reason: String? = null,
): Verification<String> = apply {
    scope.failIf({ value.isBlank() }) {
        NotBlankViolation(
            validationPath = scope.validationContext.validationPath(),
            reason = reason ?: "Value must not be blank",
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Your rules look exactly the same:

fun Verification<Int>.isPositive(
    reason: String? = null,
): Verification<Int> = apply {
    scope.failIf({ value <= 0 }) {
        violation(reason ?: "Value must be positive") // or your own violation class
    }
}

// Used exactly like any built-in rule
verify(user::age).isPositive()
Enter fullscreen mode Exit fullscreen mode

Structured violations

Violations don't have to be strings. They can be typed objects carrying whatever data you need. Your frontend can use the type directly to pick the right localized message. Your tests can assert the violation type, not fragile message text.

data class InvalidEmailViolation(
    override val reason: String,
    val actual: String,
) : Violation

fun Verification<String>.isEmail(
    reason: String = "Value is not a valid email",
): Verification<String> = apply {
    scope.failIf({ !value.matches(EmailRegex) }) {
        InvalidEmailViolation(reason = reason, actual = value)
    }
}

// In tests
assertIs<InvalidEmailViolation>(violations.first())

// Localize without parsing strings
when (violation) {
    is InvalidEmailViolation -> "\"${violation.actual}\" non est electronica inscriptio valida"
}
Enter fullscreen mode Exit fullscreen mode

Path tracking

verify(user::username) uses a property reference - the property name becomes the path segment automatically. Rename a property and the path updates with it. No strings to maintain manually.

validateCollecting {
    verify(team::members).sizeRange(1, 10).each { member ->
        verify(member::name).notBlank().minLength(2).maxLength(50) // path: "members", [index], "name"
        verify(member::role).notBlank().oneOf(setOf("admin", "member", "viewer")) // path: "members", [index], "role"
        verify(member::age).between(18, 65) // path: "members", [index], "age"
    }
}
Enter fullscreen mode Exit fullscreen mode

Collect vs throw

Same rules. Different behavior. One word changes.

// extract into a function for reuse across collect, throw, or any custom scope
fun ValidationScope.validateTeam(team: Team) {
    verify(team::members).sizeRange(1, 10).each { member ->
        verify(member::name).notBlank().minLength(2).maxLength(50)
        verify(member::role).notBlank().oneOf(setOf("admin", "member", "viewer"))
        verify(member::age).between(18, 65)
    }
}

fun main() {
    // Collect every violation
    val result = validateCollecting { validateTeam(team) }
    result.violations.forEach { println(it) }

    // Throw on the first one
    validateThrowing { validateTeam(team) }

    // Or your own scope implementation
    validateItYourWay { validateTeam(team) }
}
Enter fullscreen mode Exit fullscreen mode

The validation logic doesn't know or care which strategy is active. Swap freely without touching a single rule.


Try it

Kotlin DSL:

implementation("io.github.kverify:kverify-rule-set:2.1.0")
Enter fullscreen mode Exit fullscreen mode

Groovy:

implementation 'io.github.kverify:kverify-rule-set:2.1.0'
Enter fullscreen mode Exit fullscreen mode

Maven:


<dependency>
    <groupId>io.github.kverify</groupId>
    <artifactId>kverify-rule-set</artifactId>
    <version>2.1.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Full setup guide and documentation: github.com/KVerify/kverify/wiki


If this looks useful, a ⭐ on GitHub goes a long way for a solo-maintained library. Issues, ideas, and contributions are very welcome - github.com/KVerify/kverify

Top comments (0)