The ValidatedMonad:
Accumulating Errors with Validated
- Why fail-fast validation frustrates your users (and how to fix it)
- The difference between
flatMap(fail-fast) andapwithSemigroup(error-accumulating) - Building a complete form validator that reports ALL errors in one pass
- Using
ValidatedMonadas aMonadErrorfor recovery and error transformation - When to reach for
ValidatedvsEithervsValidationPath
The Problem: Death by a Thousand Submits
A user fills out your registration form. They make three mistakes — empty name, malformed email, and age below 18. They hit Submit.
Your validation reports: "Name is required."
They fix the name, hit Submit again. Now they see: "Invalid email format."
They fix the email, hit Submit a third time. Finally: "Must be at least 18."
Three round-trips. Three frustrations. Three errors that were all knowable from the very first submission.
This is fail-fast validation. With a monadic chain (flatMap), each step depends on the previous one succeeding, so the first failure stops everything:
// flatMap chain — validation stops at the FIRST error
validateName("") // Invalid(["Name is required"]) <-- STOPS HERE
.flatMap(name ->
validateEmail("x@@") // never reached
.flatMap(email ->
validateAge(15))); // never reached
What you actually want is to run ALL validations independently and collect every error in one pass. That is exactly what Validated with applicative operations gives you.
FAIL-FAST (flatMap): ACCUMULATE (ap + Semigroup):
validateName("") --> Invalid validateName("") --> Invalid("Name is required")
| validateEmail("x@@") --> Invalid("Invalid email")
STOP HERE validateAge(15) --> Invalid("Must be 18+")
|
combine all errors
|
Invalid(["Name is required",
"Invalid email",
"Must be 18+"])
- flatMap: Each step depends on the previous result. If step 1 fails, steps 2 and 3 never run. Use this when validations are dependent.
- ap + Semigroup: All steps run independently. Errors are combined using a
Semigroup<E>. Use this when validations are independent.
Core Components
Validated Type — Valid(value) or Invalid(error):
Monadic Structure — ValidatedMonad<E> enables monadic operations on ValidatedKind.Witness<E>:
| Component | Role |
|---|---|
Validated<E, A> | Valid(value) or Invalid(error) — like Either but validation-focused |
ValidatedKind<E, A> / ValidatedKindHelper | HKT bridge: widen(), narrow(), valid(), invalid() |
ValidatedMonad<E> | MonadError<ValidatedKind.Witness<E>, E> — fail-fast map/flatMap, plus raiseError/handleErrorWith |
Both represent success or failure. The difference is in applicative behaviour:
Either: Always fail-fast in both Monad and Applicative contexts.Validated: Fail-fast as a Monad (flatMap), but can accumulate errors as an Applicative (apwithSemigroup).
If you only ever need fail-fast semantics, use Either.
Building a Complete Form Validator
Let's build a registration validator that validates username, email, and age — reporting ALL errors in one pass.
Each validator is an independent function that returns Validated<List<String>, T>:
ValidatedMonad<List<String>> validatedMonad = ValidatedMonad.instance();
static Validated<List<String>, String> validateName(String name) {
return (name == null || name.isBlank())
? Validated.invalid(List.of("Name is required"))
: Validated.valid(name.trim());
}
static Validated<List<String>, String> validateEmail(String email) {
return (email == null || !email.matches("^[^@]+@[^@]+\\.[^@]+$"))
? Validated.invalid(List.of("Invalid email format"))
: Validated.valid(email.trim());
}
static Validated<List<String>, Integer> validateAge(int age) {
return (age < 18)
? Validated.invalid(List.of("Must be at least 18 years old"))
: Validated.valid(age);
}
Each validator works independently. None depends on another's result. This is the key to accumulation.
When validations depend on each other, use flatMap. The chain stops at the first failure:
// Dependent validation: email domain must match company based on user's role
Kind<ValidatedKind.Witness<List<String>>, String> result =
validatedMonad.flatMap(
name -> validatedMonad.flatMap(
email -> VALIDATED.valid("User: " + name + " <" + email + ">"),
VALIDATED.widen(validateEmail("alice@example.com"))
),
VALIDATED.widen(validateName("Alice"))
);
// Valid("User: Alice <alice@example.com>")
// If the first step fails, everything stops:
Kind<ValidatedKind.Witness<List<String>>, String> failed =
validatedMonad.flatMap(
name -> validatedMonad.flatMap(
email -> VALIDATED.valid("User: " + name + " <" + email + ">"),
VALIDATED.widen(validateEmail("bad@@")) // never reached
),
VALIDATED.widen(validateName("")) // Invalid — stops here
);
// Invalid(["Name is required"])
When validations are independent, use ap with a Semigroup. All validators run, and errors combine:
// All three validators run regardless of individual failures
Validated<List<String>, String> name = validateName(""); // Invalid
Validated<List<String>, String> email = validateEmail("bad@@"); // Invalid
Validated<List<String>, Integer> age = validateAge(15); // Invalid
// Using map3 to combine — ALL errors are collected:
Kind<ValidatedKind.Witness<List<String>>, String> result = validatedMonad.map3(
VALIDATED.widen(name),
VALIDATED.widen(email),
VALIDATED.widen(age),
(n, e, a) -> "User(" + n + ", " + e + ", age=" + a + ")"
);
Validated<List<String>, String> finalResult = VALIDATED.narrow(result);
// Invalid(["Name is required", "Invalid email format", "Must be at least 18 years old"])
// ^^^ ALL three errors in one pass!
map3 uses ap under the hood — it lifts the combining function into the Validated context and applies each argument, accumulating errors via the Semigroup. The mapN family (map2, map3, map4) is the recommended way to combine independent validations without writing curried functions manually.
ValidatedMonad<E> implements MonadError, so you get structured recovery:
Kind<ValidatedKind.Witness<List<String>>, Integer> failed =
validatedMonad.raiseError(List.of("Something went wrong"));
// handleErrorWith — recover to a Valid state
Kind<ValidatedKind.Witness<List<String>>, Integer> recovered =
validatedMonad.handleErrorWith(failed, errors -> validatedMonad.of(0));
// Valid(0)
// handleErrorWith — transform the error
Kind<ValidatedKind.Witness<List<String>>, Integer> transformed =
validatedMonad.handleErrorWith(failed,
errors -> validatedMonad.raiseError(
List.of("Transformed: " + errors.getFirst())));
// Invalid(["Transformed: Something went wrong"])
// handleError — recover with a plain value
Kind<ValidatedKind.Witness<List<String>>, Integer> fallback =
validatedMonad.handleError(failed, errors -> -1);
// Valid(-1)
// Valid values pass through untouched — handler is never called
Kind<ValidatedKind.Witness<List<String>>, Integer> ok = validatedMonad.of(42);
Kind<ValidatedKind.Witness<List<String>>, Integer> stillOk =
validatedMonad.handleErrorWith(ok, errors -> validatedMonad.of(0));
// Valid(42) — handler was never invoked
Working with Validated<E, A> Directly
Beyond the typeclass, the Validated type itself offers useful methods:
isValid()/isInvalid()— check the state.get()/getError()— extract value or error (throwsNoSuchElementExceptionon wrong case).orElse(other)/orElseGet(supplier)/orElseThrow(supplier)— safe extraction with fallbacks.fold(invalidMapper, validMapper)— eliminate both cases into a single result.map,flatMap,ap— also available directly onValidatedinstances.
Does step B depend on step A's result?
|
+-- YES --> flatMap (fail-fast)
| "Validate email, THEN check if domain matches company"
|
+-- NO --> ap + Semigroup (accumulate)
"Validate name AND email AND age independently"
Many real-world forms mix both: independent field validations (accumulate), followed by cross-field checks (fail-fast).
When to Use Validated
| Scenario | Use |
|---|---|
| Form/input validation — want ALL errors at once | Validated with ap + Semigroup |
| Sequential validation — later steps depend on earlier results | ValidatedMonad (fail-fast via flatMap) |
| Typed errors with arbitrary branching | Prefer Either |
| Application-level validation with fluent API | Prefer ValidationPath |
| Mix of independent + dependent validations | Independent fields via ap, then cross-field checks via flatMap |
Validated<E, A>isValid(value)orInvalid(error)— explicitly models validation outcomes.- Monadic operations (
flatMapviaValidatedMonad) are fail-fast: the firstInvalidshort-circuits the chain. - Applicative operations (
apwith aSemigroup<E>) accumulate errors from independent validations. ValidatedMonad<E>implementsMonadError<ValidatedKind.Witness<E>, E>, giving youraiseErrorandhandleErrorWithfor structured recovery.- The choice between
flatMapandapis a design decision: dependent validations use flatMap, independent validations use ap. ValidatedKindHelperprovides zero-costwiden()/narrow()casts for HKT integration.
For most use cases, prefer ValidationPath which wraps Validated and provides:
- Fluent composition with
map,via,recover - Seamless integration with the Focus DSL for structural navigation
- A consistent API shared across all effect types
- Error accumulation when combined with applicative operations
// Instead of manual Validated chaining:
Validated<List<Error>, User> user = validateUser(input);
Validated<List<Error>, Order> order = user.flatMap(u -> createOrder(u));
// Use ValidationPath for cleaner composition:
ValidationPath<List<Error>, Order> order = Path.validation(validateUser(input))
.via(u -> createOrder(u));
See Effect Path Overview for the complete guide.
Validated has dedicated JMH benchmarks. Key expectations:
invalidMapreuses the same Invalid instance with zero allocation (like Either.Left)invalidLongChainbenefits from sustained instance reuse over deep chains- If Invalid operations allocate memory, instance reuse is broken
./gradlew :hkj-benchmarks:jmh --includes=".*ValidatedBenchmark.*"
See Benchmarks & Performance for full details.