Guard<T>

Runnable example: GuardSample.java

What is Guard<T>?

Guard<T> is a @FunctionalInterface that wraps a single validation rule: a predicate over T with an associated error message. Applying a guard via check(value) returns a Validated<NonEmptyList<String>, T>Valid(value) when the rule passes, or Invalid(errors) when it fails. All composition operators (and, or, negate, andThen, contramap, withMessage, mapMessages) are default methods on the interface, so guards can be defined as lambdas and composed without inheritance; the static factories allOf and anyOf compose several guards at once. The binary combinators accept Guard<? super T>, mirroring Predicate.and/or, so a guard written against a supertype composes into a narrower guard. This design decision is documented in ADR-011 — Guard<T> as a @FunctionalInterface with default methods.

The error type is fixed as NonEmptyList<String> — this keeps composition simple (no external Monoid<E> needed for and/or) and guarantees at least one error is always present. This design decision is documented in ADR-005 — Guard<T> accumulates errors as a fixed NonEmptyList<String>.

Guards are the composable building block for Validated pipelines. Instead of writing repeated if/Validated.invalidNel(...) branches, define each rule once as a Guard and compose them with and, or, and negate.

Creating guards

// Static error message
Guard<String> notBlank = Guard.of(s -> !s.isBlank(), "must not be blank");
Guard<String> minLength3 = Guard.of(s -> s.length() >= 3, "must be at least 3 chars");
// Dynamic error message — the failing value is passed to the function
Guard<Integer> max = Guard.of(
n -> n <= 100,
n -> "must be ≤ 100, got " + n
);
// Apply
notBlank.check("hello"); // Valid("hello")
notBlank.check(" "); // Invalid(["must not be blank"])
max.check(150); // Invalid(["must be ≤ 100, got 150"])
// Built-in: nonNull() — accepts @Nullable T, rejects null
Guard<@Nullable String> nonNull = Guard.nonNull();
nonNull.check("hello"); // Valid("hello")
nonNull.check(null); // Invalid(["must not be null"])
// Compose nonNull with further rules using andThen() — short-circuits on Invalid
// so the RHS predicate never receives null
Guard<@Nullable String> nonNullAndNotBlank = Guard.<@Nullable String>nonNull()
.andThen(Guard.<@Nullable String>of(s -> s != null && !s.isBlank(), "must not be blank"));

Both factory overloads throw NullPointerException for null predicate or message arguments. The predicate itself must not throw — any exception escapes check() unwrapped. For a predicate that may throw on malformed input (parsing, regex), use Guard.ofCatching(predicate, message), which folds a thrown RuntimeException into Invalid([message]):

Guard<String> numeric = Guard.ofCatching(
s -> Integer.parseInt(s) >= 0, // parseInt throws on non-numeric input
"must be a non-negative number");
numeric.check("42"); // Valid("42")
numeric.check("abc"); // Invalid(["must be a non-negative number"]) — exception folded

Built-in guards

Guard.nonNull()

Returns a Guard<@Nullable T> that accepts a nullable input and produces Invalid(["must not be null"]) when the value is null, or Valid(value) otherwise.

The type parameter is declared <@Nullable T> so the compiler knows that passing null is intentional — no @SuppressWarnings needed:

Guard<@Nullable String> nonNull = Guard.nonNull();
nonNull.check("hello"); // Valid("hello")
nonNull.check(null); // Invalid(["must not be null"])

To chain nonNull() with further rules, use andThen() rather than and(). and() always evaluates both sides, so a downstream Guard.of(s -> !s.isBlank(), ...) would receive null and throw when the input is null. andThen() short-circuits: if this guard returns Invalid, the next guard is never called.

Guard<@Nullable String> nonNullAndNotBlank = Guard.<@Nullable String>nonNull()
.andThen(Guard.<@Nullable String>of(s -> s != null && !s.isBlank(), "must not be blank"));
nonNullAndNotBlank.check("hello"); // Valid("hello")
nonNullAndNotBlank.check(null); // Invalid(["must not be null"]) — RHS never called
nonNullAndNotBlank.check(" "); // Invalid(["must not be blank"])

and(other) — accumulate all errors

Both guards are always evaluatedand is not fail-fast. Errors from all failing guards are accumulated into a single NonEmptyList so the caller receives a complete picture of every violation.

Guard<String> notBlank = Guard.of(s -> !s.isBlank(), "must not be blank");
Guard<String> minLength3 = Guard.of(s -> s.length() >= 3, "min 3 chars");
Guard<String> alphanumeric = Guard.of(s -> s.matches("[a-zA-Z0-9]+"), "must be alphanumeric");
// Both guards are always evaluated — errors accumulate (not fail-fast)
Guard<String> username = notBlank.and(minLength3).and(alphanumeric);
username.check("alice"); // Valid("alice")
username.check("a!"); // Invalid(["min 3 chars", "must be alphanumeric"])
username.check(" "); // Invalid(["must not be blank", "min 3 chars", "must be alphanumeric"])

or(other) — pass on first success

Evaluation is short-circuit: if the left guard passes, the right guard is never evaluated. If all guards fail, errors from all of them are accumulated.

Guard<String> email = Guard.of(s -> s.contains("@"), "must contain @");
Guard<String> phone = Guard.of(s -> s.matches("\\d+"), "must be all digits");
// First passing guard short-circuits; all errors accumulated on total failure
Guard<String> contact = email.or(phone);
contact.check("alice@example.com"); // Valid — email passes, phone not evaluated
contact.check("12345"); // Valid — phone passes
contact.check("hello"); // Invalid(["must contain @", "must be all digits"])

Use or when a value is allowed to satisfy any one of several alternative formats (e.g., email or phone number, UUID or slug).

allOf(...) / anyOf(...) — compose several guards at once

Instead of chaining .and() or .or() calls, the varargs factories compose a set of rules in one expression. The first guard is a mandatory parameter, so an empty composition does not compile. allOf evaluates every guard and accumulates errors from all that fail (same semantics as chained and, in a single pass); anyOf short-circuits on the first pass and accumulates every error when all fail (same as chained or):

Guard<String> username = Guard.allOf(notBlank, minLength3, alphanumeric);
Guard<String> contact = Guard.anyOf(email, phone);
username.check("a?"); // Invalid(["must be at least 3 chars", "must be alphanumeric"])
contact.check("alice@acme.com"); // Valid — phone never evaluated

negate() / negate(message) / negate(messageFromValue)

Inverts the predicate. Three overloads are available:

Overload Error message when original guard passes
negate() Generic: "must not satisfy the condition"
negate(String message) Fixed string supplied by the caller
negate(Function<T,String> fn) Dynamic — the function receives the passing value
// negate(message) — inverts the predicate with a domain-specific error
Guard<String> noAdmin = Guard.<String>of(s -> s.equals("admin"), "reserved")
.negate("username must not be 'admin'");
noAdmin.check("alice"); // Valid("alice")
noAdmin.check("admin"); // Invalid(["username must not be 'admin'"])
// negate() with no argument uses a generic message
Guard<String> notReserved = Guard.<String>of(s -> s.equals("root"), "is root").negate();
notReserved.check("root"); // Invalid(["must not satisfy the condition"])
// negate(messageFromValue) — dynamic message from the failing value
Guard<String> notReservedDynamic = Guard.<String>of(
s -> s.equals("admin") || s.equals("root"), "reserved"
).negate(s -> "username '" + s + "' is reserved");
notReservedDynamic.check("alice"); // Valid("alice")
notReservedDynamic.check("admin"); // Invalid(["username 'admin' is reserved"])
notReservedDynamic.check("root"); // Invalid(["username 'root' is reserved"])

withMessage(message)

Replaces all error messages produced by this guard with a single fixed message. Useful at public API boundaries where you want to hide internal validation details and expose one clean, user-facing message regardless of which rule failed:

Guard<String> username = notBlank.and(minLength3).and(alphanumeric)
.withMessage("invalid username");
username.check(""); // Invalid(["invalid username"])
username.check("a"); // Invalid(["invalid username"]) — not "min 3 chars"
username.check("alice"); // Valid("alice")

mapMessages(transform)

Rewrites each accumulated error message individually — the general form behind field prefixing. Where withMessage collapses everything into one fixed message, mapMessages keeps every error and transforms it:

Guard<String> username = notBlank.and(minLength3)
.mapMessages(m -> "user.name: " + m);
username.check(""); // Invalid(["user.name: must not be blank", "user.name: must be at least 3 chars"])

contramap(fn) and contramap(fn, fieldName)

contramap adapts a field-level guard to validate the enclosing object. The fieldName overload additionally prefixes each error with the field name, so accumulated messages stay unambiguous when several field guards are combined on one object:

Guard<Account> accountGuard = Guard.allOf(
notBlank.contramap(Account::username, "username"),
email.contramap(Account::email, "email"));
accountGuard.check(new Account(" ", "oops"));
// Invalid(["username: must not be blank", "email: must contain @"])

Integrating with Validated.combine

Guards produce Validated<NonEmptyList<String>, T> values, which compose naturally with Validated.combine. Apply one guard per field, then combine them to accumulate errors across an entire form or command object:

Validated<NonEmptyList<String>, String> username = usernameGuard.check(rawUsername);
Validated<NonEmptyList<String>, String> email = emailGuard.check(rawEmail);
Validated<NonEmptyList<String>, Form> form =
username.combine(email, NonEmptyList::concat, Form::new);
// Invalid(["min 3 chars", "must be alphanumeric", "invalid email format"])

Interoperability

Method Returns Use case
withMessage(message) Guard<T> Replace all errors with one fixed message (hides internal rule details)
mapMessages(transform) Guard<T> Rewrite each error message (prefixes, i18n keys, structured formats)
asPredicate() Predicate<T> Stream.filter, Collection.removeIf, any standard Java predicate API
contramap(fn) Guard<U> Adapt a field guard to validate the enclosing object type
contramap(fn, fieldName) Guard<U> Same, prefixing each error with the field name
Guard.narrow(guard) Guard<T> Adapt a Guard<? super T> where a Guard<T> is required
checkToResult(value) Result<T, NonEmptyList<String>> Direct integration into Result-based domain logic
checkToResult(value, mapper) Result<T, E> Map errors to a domain-specific error type at service boundaries
checkToEither(value) Either<NonEmptyList<String>, T> Integrate with Either-based pipelines (left = errors, right = value)
checkToTry(value) Try<T> Integrate with Try-based pipelines; errors joined as IllegalArgumentException
checkToTry(value, toThrowable) Try<T> Integrate with Try-based pipelines; caller controls the exception type
checkToOption(value) Option<T> Discard errors — keep only valid values (stream filtering)
checkToOptional(value) Optional<T> Java standard Optional interop — drop-in for APIs expecting Optional
Guard<String> notBlank = Guard.of(s -> !s.isBlank(), "must not be blank");
Guard<String> minLength3 = Guard.of(s -> s.length() >= 3, "min 3 chars");
Guard<String> username = notBlank.and(minLength3);
// asPredicate() — Java stdlib interop
List<String> validNames = Stream.of("alice", " ", "bob")
.filter(notBlank.asPredicate()) // standard Predicate<String>
.toList();
// ["alice", "bob"]
// contramap(fn) — adapt a field guard to an enclosing type
record User(String name) {}
Guard<User> userGuard = notBlank.contramap(User::name);
userGuard.check(new User("alice")); // Valid(User[name=alice])
userGuard.check(new User(" ")); // Invalid(["must not be blank"])
// checkToResult(value) — direct Result<T, NonEmptyList<String>>
Result<String, NonEmptyList<String>> r1 = username.checkToResult("alice");
// Result.ok("alice")
// checkToResult(value, mapper) — map errors to a domain type
Result<String, String> r2 = username.checkToResult(
"a!", errors -> String.join("; ", errors.toList()));
// Result.err("min 3 chars")
// checkToEither(value) — Either<NonEmptyList<String>, T>
Either<NonEmptyList<String>, String> right = username.checkToEither("alice");
// Either.right("alice")
Either<NonEmptyList<String>, String> left = username.checkToEither("a!");
// Either.left(NonEmptyList.of("min 3 chars"))
// checkToTry(value) — Try<T> with default IllegalArgumentException
Try<String> success = username.checkToTry("alice");
// Try.success("alice")
Try<String> failure = username.checkToTry("a!");
// Try.failure(new IllegalArgumentException("min 3 chars"))
// checkToTry(value, toThrowable) — caller controls the exception type
Try<String> typed = username.checkToTry(
"a!",
errors -> new ValidationException(errors.toList())
);
// Try.failure(new ValidationException(["min 3 chars"]))
// checkToOption(value) — discard errors, keep value or None
Option<String> opt = username.checkToOption("alice"); // Some("alice")
username.checkToOption("a!"); // None
// flatMap over a stream, keeping only valid values
List<String> valid = Stream.of("alice", "a!", "bob")
.flatMap(s -> username.checkToOption(s).stream())
.toList();
// ["alice", "bob"]
// checkToOptional(value) — Java standard Optional<T>
Optional<String> present = username.checkToOptional("alice"); // Optional.of("alice")
Optional<String> empty = username.checkToOptional("a!"); // Optional.empty()
// integrates naturally with Optional chaining
String upper = username.checkToOptional(input)
.map(String::toUpperCase)
.orElse("INVALID");
// withMessage(message) — replace all errors with a single fixed message
Guard<String> usernameSafe = notBlank.and(minLength3).withMessage("invalid username");
usernameSafe.check(""); // Invalid(["invalid username"])
usernameSafe.check("a"); // Invalid(["invalid username"])
usernameSafe.check("alice"); // Valid("alice")

Real-world example

A registration form validator that composes rules across three fields:

// ---- Define reusable rules once ----
Guard<String> notBlank = Guard.of(s -> !s.isBlank(), "must not be blank");
Guard<String> minLength3 = Guard.of(s -> s.length() >= 3, "min 3 chars");
Guard<String> maxLength50 = Guard.of(s -> s.length() <= 50, "max 50 chars");
Guard<String> alphanumeric = Guard.of(s -> s.matches("[a-zA-Z0-9_]+"), "must be alphanumeric or _");
Guard<String> emailFormat = Guard.of(s -> s.contains("@") && s.contains("."), "invalid email format");
Guard<Integer> adultAge = Guard.of(n -> n >= 18, "must be at least 18");
// ---- Compose into domain rules ----
Guard<String> usernameGuard = notBlank.and(minLength3).and(maxLength50).and(alphanumeric);
Guard<String> emailGuard = notBlank.and(emailFormat);
// ---- Validate all fields and accumulate errors ----
static Validated<NonEmptyList<String>, RegistrationForm> validate(
String rawUsername, String rawEmail, int age) {
Validated<NonEmptyList<String>, String> username = usernameGuard.check(rawUsername);
Validated<NonEmptyList<String>, String> email = emailGuard.check(rawEmail);
Validated<NonEmptyList<String>, Integer> ageV = adultAge.check(age);
return username
.combine(email, NonEmptyList::concat, Tuple2::new)
.combine(ageV, NonEmptyList::concat, (ue, a) -> new RegistrationForm(ue._1(), ue._2(), a));
}
// validate("al", "not-an-email", 15)
// → Invalid(["min 3 chars", "invalid email format", "must be at least 18"])

Guard vs raw Validated

Raw Validated.invalidNel Guard<T>
Rule definition Inline if/invalidNel Named value — defined once, used anywhere
Reuse across call sites Copy-paste Reference the same Guard constant
Composition Manual chaining and, or, negate
Error accumulation Explicit combine calls Built into and / or
Dynamic error messages Inline string interpolation Guard.of(pred, value -> "... " + value)

Use Guard when the same rule appears in more than one place, when rules are composed from smaller building blocks, or when you want a named, self-documenting validation vocabulary. For a single, one-off check, a direct Validated.invalidNel is simpler.