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) }
}
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) }
}
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")
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
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",
)
}
}
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()
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"
}
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"
}
}
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) }
}
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")
Groovy:
implementation 'io.github.kverify:kverify-rule-set:2.1.0'
Maven:
<dependency>
<groupId>io.github.kverify</groupId>
<artifactId>kverify-rule-set</artifactId>
<version>2.1.0</version>
</dependency>
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)