Validation¶
Validation<E, A> is Valid(A value) or Invalid(NonEmptyVector<E> errors). Either stops at the first
failure; combining validations keeps the errors of every one of them, in order.
The errors are held in a NonEmptyVector, so an Invalid always carries at least one.
Checks¶
A check returns a Validation. fromPredicate builds one from a test, and from a function that turns the rejected
value into an error message.
static Validation<String, String> name(String value) {
return value.isBlank() ? Validation.invalid("name is blank") : Validation.valid(value);
}
static Validation<String, Integer> age(int value) {
return Validation.fromPredicate(value, v -> v >= 0, v -> "age is negative");
}
static Validation<String, String> email(String value) {
return Validation.fromPredicate(value, v -> v.contains("@"), v -> "email has no @");
}
fromEither, fromOption and fromTry convert the other control types.
Accumulation with zip and zipWith¶
zipWith combines independent checks and calls the function only when all of them are valid. The static form
takes 2 to 8 checks (zip at arity N); the instance form combines two.
record User(String name, int age, String email) {}
Validation<String, User> user = Validation.zipWith(name(""), age(-1), email("jules"), User::new);
// Invalid(name is blank, age is negative, email has no @)
Validation<String, Tuple2<String, Integer>> pair = name("Ada").zip(age(36));
Validation<String, String> both = name("").zipWith(age(-1), (n, a) -> n + a);
// Valid((Ada, 36)), Invalid(name is blank, age is negative)
zip(Either) treats a Left as one more error: Invalid(a) zipped with Left(b) is Invalid(a, b).
Reading the result¶
switch over the records, or fold with the errors first:
Validation<String, Integer> checked = age(-5);
String message = switch (checked) {
case Valid(var years) -> "age " + years;
case Invalid(var errors) -> errors.size() + " error(s): " + errors.mkString("; ");
};
// "1 error(s): age is negative"
toEither() gives an Either<NonEmptyVector<E>, A>, for code that expects one.
Many values: collectAll, forEach, partition¶
collectAll turns many validations into one validation of a Vector, keeping every error. forEach does the
same after running a check on each input.
Validation<String, Vector<Integer>> ages = Validation.forEach(Vector.of(3, -1, 7, -2), n -> age(n));
// Invalid(age is negative, age is negative)
Validation<String, Vector<String>> names = Validation.collectAll(Vector.of(name("Ada"), name("Grace")));
// Valid(Vector(Ada, Grace))
forEach over a NonEmptyVector returns a NonEmptyVector on the valid side too.
Validation<String, NonEmptyVector<Integer>> scores = Validation.forEach(NonEmptyVector.of(1, 2), n -> age(n));
// Valid(NonEmptyVector(1, 2))
partition never fails: it returns the errors and the successes side by side.
Tuple2<Vector<String>, Vector<Integer>> split = Validation.partition(Vector.of(4, -1, 9), n -> age(n));
// (Vector(age is negative), Vector(4, 9))
Short-circuiting on purpose: flatMap¶
flatMap runs the next check only when this one is valid, so the two checks' errors are never combined. Use it
for a rule that needs the valid value, after the independent checks:
Validation<String, User> adult = Validation.zipWith(name("Ada"), age(15), email("ada@example.com"), User::new)
.flatMapEither(u -> u.age() >= 18 ? Either.right(u) : Either.left(u.name() + " is under 18"));
// Invalid(Ada is under 18)
flatMapEither is the same for a step that returns an Either. If every step depends on the previous one, Either
is the simpler type.
orElse does not combine errors: when both sides are invalid, it returns the second one and drops the first one's
errors.