kap-arrow¶
Arrow integration for parallel validation with error accumulation.
Depends on: kap-core + Arrow Core.
Platforms: JVM only.
Tests: 223 tests across 10 test classes.
The Problem — Validation Round Trips¶
// Sequential: returns FIRST error, user must fix and resubmit for each one
suspend fun registerUser(name: String, email: String, age: Int, username: String): User {
val validName = validateName(name) // ← fails here? stops
val validEmail = validateEmail(email) // ← never reached
val validAge = validateAge(age) // ← never reached
val validUsername = checkUsername(username) // ← never reached
return User(validName, validEmail, validAge, validUsername)
}
// 5 invalid fields = 5 round trips = terrible UX
Writing Validators¶
Each validator returns Either<NonEmptyList<E>, A>:
sealed class RegError(val message: String) {
class InvalidName(msg: String) : RegError(msg)
class InvalidEmail(msg: String) : RegError(msg)
class InvalidAge(msg: String) : RegError(msg)
class WeakPassword(msg: String) : RegError(msg)
class UsernameTaken(msg: String) : RegError(msg)
}
data class ValidName(val value: String)
data class ValidEmail(val value: String)
data class ValidAge(val value: Int)
data class ValidUsername(val value: String)
data class ValidPassword(val value: String)
data class User(val name: ValidName, val email: ValidEmail, val age: ValidAge, val username: ValidUsername)
suspend fun validateName(name: String): Either<NonEmptyList<RegError>, ValidName> {
delay(20)
return if (name.length >= 2) Either.Right(ValidName(name))
else Either.Left(nonEmptyListOf(RegError.InvalidName("Name must be >= 2 chars")))
}
suspend fun validateEmail(email: String): Either<NonEmptyList<RegError>, ValidEmail> {
delay(15)
return if ("@" in email) Either.Right(ValidEmail(email))
else Either.Left(nonEmptyListOf(RegError.InvalidEmail("Invalid email: $email")))
}
suspend fun validateAge(age: Int): Either<NonEmptyList<RegError>, ValidAge> {
delay(10)
return if (age >= 18) Either.Right(ValidAge(age))
else Either.Left(nonEmptyListOf(RegError.InvalidAge("Must be >= 18, got $age")))
}
suspend fun checkUsername(username: String): Either<NonEmptyList<RegError>, ValidUsername> {
delay(25) // async DB check
return if (username.length >= 3) Either.Right(ValidUsername(username))
else Either.Left(nonEmptyListOf(RegError.UsernameTaken("Username too short")))
}
zipV — Parallel Validation (2-22 args)¶
All pass¶
// Sequential: if all pass you get the result, but no parallelism
suspend fun registerUser(name: String, email: String, age: Int, username: String): User {
val validName = validateName("Alice").getOrElse { throw ValidationException(it) }
val validEmail = validateEmail("alice@example.com").getOrElse { throw ValidationException(it) }
val validAge = validateAge(25).getOrElse { throw ValidationException(it) }
val validUsername = checkUsername("alice").getOrElse { throw ValidationException(it) }
return User(validName, validEmail, validAge, validUsername)
}
// Works when all pass, but runs sequentially — no parallel speedup
val valid: Either<NonEmptyList<RegError>, User> = zipV(
{ validateName("Alice") },
{ validateEmail("alice@example.com") },
{ validateAge(25) },
{ checkUsername("alice") },
) { name, email, age, username -> User(name, email, age, username) }
.evalGraph()
// Right(User(ValidName(Alice), ValidEmail(alice@example.com), ValidAge(25), ValidUsername(alice)))
All fail — every error collected¶
// Sequential: returns FIRST error only, user must fix and resubmit
suspend fun registerUser(): User {
val validName = validateName("A").getOrElse { throw ValidationException(it) }
// ↑ fails here — never reaches the rest
val validEmail = validateEmail("bad").getOrElse { throw ValidationException(it) }
val validAge = validateAge(10).getOrElse { throw ValidationException(it) }
val validUsername = checkUsername("al").getOrElse { throw ValidationException(it) }
return User(validName, validEmail, validAge, validUsername)
}
// Only InvalidName reported — 4 errors = 4 round trips
val invalid: Either<NonEmptyList<RegError>, User> = zipV(
{ validateName("A") }, // ← too short
{ validateEmail("bad") }, // ← no @
{ validateAge(10) }, // ← under 18
{ checkUsername("al") }, // ← too short
) { name, email, age, username -> User(name, email, age, username) }
.evalGraph()
// Left(NonEmptyList(InvalidName, InvalidEmail, InvalidAge, UsernameTaken))
// ALL 4 errors in ONE response. All ran in parallel.
val result = Either.zipOrAccumulate(
{ validateName("A") }, // ← too short
{ validateEmail("bad") }, // ← no @
{ validateAge(10) }, // ← under 18
{ checkUsername("al") }, // ← too short
) { name, email, age, username -> User(name, email, age, username) }
// Same error accumulation, but max 9 args and not parallel
Scales to 22 validators. Arrow's zipOrAccumulate maxes at 9.
kapV + withV — Curried Builder¶
Same parallel execution and error accumulation, typed chain syntax:
// Sequential calls, no error accumulation, no type-safe builder
suspend fun registerUser(): Either<NonEmptyList<RegError>, User> {
val name = validateName("Alice").getOrElse { return Either.Left(it) }
val email = validateEmail("alice@example.com").getOrElse { return Either.Left(it) }
val age = validateAge(25).getOrElse { return Either.Left(it) }
val username = checkUsername("alice").getOrElse { return Either.Left(it) }
return Either.Right(User(name, email, age, username))
}
// No parallel execution, short-circuits on first error
Swap two .withV lines? Compile error — same type safety as kap + .with.
Phased Validation — thenV / andThenV¶
Some validations depend on earlier results. Phase 1 collects all basic errors. Only if all pass does phase 2 run:
data class Identity(val name: ValidName, val email: ValidEmail, val age: ValidAge)
data class Clearance(val notBlocked: Boolean, val available: Boolean)
data class Registration(val identity: Identity, val clearance: Clearance)
val result: Either<NonEmptyList<RegError>, Registration> = accumulate {
// Phase 1: validate basic fields in parallel, collect ALL errors
val identity = zipV(
{ validateName("Alice") },
{ validateEmail("alice@example.com") },
{ validateAge(25) },
) { name, email, age -> Identity(name, email, age) }
.bindV() // short-circuits if phase 1 fails
// Phase 2: only runs if phase 1 passed — uses identity result
val cleared = zipV(
{ checkNotBlacklisted(identity) },
{ checkUsernameAvailable(identity.email.value) },
) { a, b -> Clearance(a, b) }
.bindV()
Registration(identity, cleared)
}.evalGraph()
// Sequential validation — stops at first error
val name = validateName("Alice") // fails? stop here
val email = validateEmail("alice@ex.com") // never reached if name fails
val age = validateAge(25) // never reached if email fails
// No error accumulation, no parallel execution
// Each phase is just another if/else — no structured composition
Entry Points¶
valid(a) / invalid(e) / invalidAll(errors)¶
val success: Validated<RegError, ValidName> = valid(ValidName("Alice"))
val failure: Validated<RegError, ValidName> = invalid(RegError.InvalidName("too short"))
val multiError: Validated<RegError, ValidName> = invalidAll(
nonEmptyListOf(RegError.InvalidName("too short"), RegError.InvalidName("no numbers"))
)
catching(toError) { } — Exception to error bridge¶
val result = catching<RegError, String>({ e -> RegError.InvalidName(e.message ?: "unknown") }) {
riskyOperation()
}.evalGraph()
Guards — ensureV / ensureVAll¶
// Manual if/else — no composable guard abstraction
fun validateAge(age: ValidAge): Either<NonEmptyList<RegError>, ValidAge> {
return if (age.value >= 18) Either.Right(age)
else Either.Left(nonEmptyListOf(RegError.InvalidAge("Must be 18+")))
}
// Multiple checks require manual error accumulation
fun validatePassword(password: ValidPassword): Either<NonEmptyList<RegError>, ValidPassword> {
val errors = buildList {
if (password.value.length < 8) add(RegError.WeakPassword("Too short"))
if (!password.value.any { it.isUpperCase() }) add(RegError.WeakPassword("No uppercase"))
if (!password.value.any { it.isDigit() }) add(RegError.WeakPassword("No digit"))
}
return if (errors.isEmpty()) Either.Right(password)
else Either.Left(nonEmptyListOf(errors.first(), *errors.drop(1).toTypedArray()))
}
// Must write a new function for every guard — no chaining
val result = valid(ValidAge(15))
.ensureV(RegError.InvalidAge("Must be 18+")) { it.value >= 18 }
.evalGraph()
// Left(NonEmptyList(InvalidAge("Must be 18+")))
val result2 = valid(ValidPassword("123"))
.ensureVAll { password ->
buildList {
if (password.value.length < 8) add(RegError.WeakPassword("Too short"))
if (!password.value.any { it.isUpperCase() }) add(RegError.WeakPassword("No uppercase"))
if (!password.value.any { it.isDigit() }) add(RegError.WeakPassword("No digit"))
}.let { if (it.isEmpty()) null else nonEmptyListOf(it.first(), *it.drop(1).toTypedArray()) }
}
.evalGraph()
// Left(NonEmptyList(WeakPassword("Too short"), WeakPassword("No uppercase")))
Transforms¶
.mapV { } — Transform success¶
val result = valid(ValidName("alice"))
.mapV { it.value.uppercase() }
.evalGraph()
// Right("ALICE")
.mapError { } — Transform error type¶
val result = invalid(RegError.InvalidName("too short"))
.mapError { ApiError(it.message) }
.evalGraph()
.recoverV { } — Recover from validation errors¶
val result = invalid(RegError.InvalidName("too short"))
.recoverV { errors -> ValidName("default-${errors.size}-errors") }
.evalGraph()
// Right(ValidName("default-1-errors"))
.orThrow() — Unwrap or throw¶
val user: User = zipV(
{ validateName("Alice") },
{ validateEmail("alice@example.com") },
{ validateAge(25) },
{ checkUsername("alice") },
) { name, email, age, username -> User(name, email, age, username) }
.orThrow() // Right → value, Left → throws
.evalGraph()
Collection Operations¶
traverseV — Validate each element¶
val emails = listOf("alice@example.com", "bad", "bob@example.com", "also-bad")
val errors = mutableListOf<RegError>()
val results = mutableListOf<ValidEmail>()
for (email in emails) {
when (val r = validateEmail(email)) {
is Either.Right -> results.add(r.value)
is Either.Left -> errors.addAll(r.value)
}
}
val result = if (errors.isEmpty()) Either.Right(results)
else Either.Left(nonEmptyListOf(errors.first(), *errors.drop(1).toTypedArray()))
// Manual loop, mutable state, no parallel execution
sequenceV — Sequence validated computations¶
val validated: List<Either<NonEmptyList<RegError>, ValidEmail>> = emails.map { email ->
validateEmail(email)
}
val errors = validated.filterIsInstance<Either.Left<NonEmptyList<RegError>>>()
.flatMap { it.value }
val results = validated.filterIsInstance<Either.Right<ValidEmail>>()
.map { it.value }
val result = if (errors.isEmpty()) Either.Right(results)
else Either.Left(nonEmptyListOf(errors.first(), *errors.drop(1).toTypedArray()))
// Manual filtering, no structured composition
Arrow Interop¶
.attempt() — Catch to Either¶
raceEither(fa, fb) — Race two different types¶
sealed class RaceResult {
data class FromA(val value: String) : RaceResult()
data class FromB(val value: Int) : RaceResult()
}
val result = coroutineScope {
select<RaceResult> {
async { delay(30); "fast-string" }.onAwait { RaceResult.FromA(it) }
async { delay(100); 42 }.onAwait { RaceResult.FromB(it) }
}
}
// Requires a sealed class wrapper for different types
// Must manually handle cancellation of the loser
accumulate { } Builder¶
For imperative-style validation with .bindV():
// Sequential, no error accumulation within phases, no parallel execution
suspend fun register(): Either<NonEmptyList<RegError>, Registration> {
val name = validateName("Alice").getOrElse { return Either.Left(it) }
val email = validateEmail("alice@example.com").getOrElse { return Either.Left(it) }
val age = validateAge(25).getOrElse { return Either.Left(it) }
val identity = Identity(name, email, age)
// Phase 2 — also sequential, also short-circuits on first error
val notBlocked = checkNotBlacklisted(identity).getOrElse { return Either.Left(it) }
val available = checkUsernameAvailable(identity.email.value).getOrElse { return Either.Left(it) }
val cleared = Clearance(notBlocked, available)
return Either.Right(Registration(identity, cleared))
}
// No parallel execution within phases, no error accumulation
val result = accumulate<RegError, Registration> {
val identity = zipV(
{ validateName("Alice") },
{ validateEmail("alice@example.com") },
{ validateAge(25) },
) { name, email, age -> Identity(name, email, age) }
.bindV() // short-circuits if phase 1 fails
val cleared = zipV(
{ checkNotBlacklisted(identity) },
{ checkUsernameAvailable(identity.email.value) },
) { a, b -> Clearance(a, b) }
.bindV()
Registration(identity, cleared)
}.evalGraph()
val result = either<NonEmptyList<RegError>, Registration> {
// Arrow's either { } block — monadic, short-circuits on first Left
val identity = Either.zipOrAccumulate(
{ validateName("Alice") },
{ validateEmail("alice@example.com") },
{ validateAge(25) },
) { name, email, age -> Identity(name, email, age) }
.bind() // short-circuits if phase 1 fails
val cleared = Either.zipOrAccumulate(
{ checkNotBlacklisted(identity) },
{ checkUsernameAvailable(identity.email.value) },
) { a, b -> Clearance(a, b) }
.bind()
Registration(identity, cleared)
}
// Similar structure, but zipOrAccumulate is not parallel and maxes at 9 args
Short-circuit vs parallel
.bindV() short-circuits (monadic): if phase 1 fails, phase 2 never runs.
zipV accumulates (applicative): all validators run, all errors collected.
Use zipV within a phase, bindV between phases.
Type Alias¶
This means all Kap combinators (.map, .timeout, .retry, .traced, etc.) work on validated computations too.