Skip to content

Validated Forms

Validate multiple fields in parallel and collect every error at once — not just the first.

Requires kap-arrow

This guide uses the kap-arrow module which provides zipV, withV, and Arrow's Either/NonEmptyList types.

The problem

Standard Kotlin validation short-circuits on the first error:

// User submits a form with 5 invalid fields.
// You return: "Name is too short"
// User fixes it, resubmits.
// You return: "Email is invalid"
// User fixes it, resubmits.
// You return: "Age must be 18+"
// ... 5 round trips for 5 errors.

The solution: parallel validation

val result: Either<NonEmptyList<RegError>, User> = Async {
    zipV(
        { validateName("Alice") },
        { validateEmail("alice@example.com") },
        { validateAge(25) },
        { checkUsername("alice") },
    ) { name, email, age, username -> User(name, email, age, username) }
}
  • All 4 validators run in parallel
  • All pass? Either.Right(User(...))
  • 3 fail? Either.Left(NonEmptyList(NameTooShort, InvalidEmail, AgeTooLow)) — every error, one response

Writing validators

Each validator returns Either<NonEmptyList<E>, A>:

sealed class RegError {
    data class NameTooShort(val min: Int) : RegError()
    data class InvalidEmail(val value: String) : RegError()
    data class AgeTooLow(val min: Int) : RegError()
    data class UsernameTaken(val name: String) : RegError()
}

suspend fun validateName(name: String): Either<NonEmptyList<RegError>, ValidName> =
    if (name.length >= 2) Either.Right(ValidName(name))
    else Either.Left(nonEmptyListOf(RegError.NameTooShort(2)))

suspend fun validateEmail(email: String): Either<NonEmptyList<RegError>, ValidEmail> =
    if (email.contains("@")) Either.Right(ValidEmail(email))
    else Either.Left(nonEmptyListOf(RegError.InvalidEmail(email)))

suspend fun validateAge(age: Int): Either<NonEmptyList<RegError>, ValidAge> =
    if (age >= 18) Either.Right(ValidAge(age))
    else Either.Left(nonEmptyListOf(RegError.AgeTooLow(18)))

suspend fun checkUsername(name: String): Either<NonEmptyList<RegError>, ValidUsername> =
    if (!isUsernameTaken(name)) Either.Right(ValidUsername(name))
    else Either.Left(nonEmptyListOf(RegError.UsernameTaken(name)))

Phased validation

Some validations depend on earlier results. Use thenV for barriers:

val result = Async {
    zipV(
        { validateName("Alice") },
        { validateEmail("alice@example.com") },
        { validateAge(25) },
    ) { name, email, age -> BasicInfo(name, email, age) }
        .thenV { info ->
            // Only runs if all 3 above pass
            zipV(
                { checkUsername(info.name.value) },
                { verifyEmailDomain(info.email.value) },
            ) { username, domain -> FullRegistration(info, username, domain) }
        }
}

Phase 1 collects all basic errors. Only if all pass does phase 2 run.

Scales to 22 validators

// Arrow's zipOrAccumulate maxes at 9 parameters.
// KAP's zipV goes to 22:
val result = Async {
    zipV(f1, f2, f3, f4, f5, f6, f7, f8, f9, f10,
         f11, f12, f13, f14, f15, f16, f17, f18, f19, f20,
         f21, f22) { /* all 22 validated values */ }
}

Using withV chains

Alternative syntax using the curried builder:

val result = Async {
    kapV(::User)
        .withV { validateName("Alice") }
        .withV { validateEmail("alice@example.com") }
        .withV { validateAge(25) }
}

Same parallel execution, same error accumulation — different style.

Setup

dependencies {
    implementation("io.github.damian-rafael-lattenero:kap-core:2.3.0")
    implementation("io.github.damian-rafael-lattenero:kap-arrow:2.3.0")
}

Try it

./gradlew :examples:validated-registration:run