Back to Blog
Java

Java BiPredicate: Two-Argument Predicates in Practice

java bipredicate: Learn how Java's BiPredicate functional interface handles two-argument boolean checks, including composition, stream integration, and validation patt...

JavaFunctional InterfacesLambda ExpressionsStreamsValidation
Illustration of a Java BiPredicate combining two input values into a single boolean result through a logical gate.

java bipredicate requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Java's BiPredicate<T, U> is a functional interface in java.util.function that accepts two arguments and returns a boolean. Its single abstract method is test(T t, U u), and it serves as the two-argument counterpart to Predicate<T>. The Bi prefix signals that the functional method takes two inputs rather than one, which is useful when a condition depends on two values that should be evaluated together. The interface also provides three default methods — and, or, and negate — that let you compose two-argument boolean checks without writing explicit if statements or helper methods.

What BiPredicate Is and Where It Fits

BiPredicate belongs to the same family as BiFunction, BiConsumer, and BiOperator. What distinguishes it is the return type: a boolean. That makes it a natural fit for conditions, filters, and validation rules where two inputs determine a yes-or-no outcome.

The interface declaration looks like this:

@FunctionalInterface public interface BiPredicate<T, U> { boolean test(T t, U u); default BiPredicate<T, U> and(BiPredicate<? super T, ? super U> other) { ... } default BiPredicate<T, U> or(BiPredicate<? super T, ? super U> other) { ... } default BiPredicate<T, U> negate() { ... } }

The wildcards in and and or mean you can compose predicates with broader parameter types. For example, a BiPredicate<Object, Object> can be combined with a BiPredicate<String, String> when the target type is BiPredicate<String, String>.

A Minimal Example

The simplest way to create a BiPredicate is with a lambda expression. The lambda parameters map directly to the two inputs of test:

BiPredicate<String, Integer> isLongerThan = (s, n) -> s.length() > n; boolean result = isLongerThan.test("hello", 3); // true

The compiler infers s as String and n as Integer from the declared type. The lambda body evaluates to a boolean, which satisfies the functional method's contract.

A method reference works when an existing method matches the two-argument shape. String.equalsIgnoreCase takes one explicit argument plus the receiver, so it fits test(String, String):

BiPredicate<String, String> equalsIgnoreCase = String::equalsIgnoreCase; boolean result = equalsIgnoreCase.test("abc", "ABC"); // true

Method references are preferable when the logic already exists as a named method, because they make the intent explicit and avoid duplicating the comparison logic in a lambda.

Combining BiPredicates with and, or, and negate

The default methods allow chaining conditions into a single predicate. Each returns a new BiPredicate instance; the original is never modified.

BiPredicate<Integer, Integer> greater = (a, b) -> a > b; BiPredicate<Integer, Integer> bothPositive = (a, b) -> a > 0 && b > 0; BiPredicate<Integer, Integer> greaterAndPositive = greater.and(bothPositive); System.out.println(greaterAndPositive.test(5, 3)); // true System.out.println(greaterAndPositive.test(5, -3)); // false

and returns true only when both operands return true. or returns true when at least one operand returns true. negate inverts the result.

These compositions are short-circuiting. In a.and(b), if a returns false, b is never evaluated. The same applies to or: if a returns true, b is skipped. This matters when the second predicate performs expensive work or has side effects.

Using BiPredicate with Streams and Collections

Stream.filter accepts a Predicate<T>, which takes one argument. To use a BiPredicate with a stream, you typically bind one of the two inputs by closing over it in a lambda:

BiPredicate<String, String> contains = (source, part) -> source.contains(part); List<String> names = List.of("alice", "bob", "carol", "dave"); String searchTerm = "a"; List<String> matches = names.stream() .filter(name -> contains.test(name, searchTerm)) .toList();

The lambda name -> contains.test(name, searchTerm) fixes the second argument to searchTerm, producing the single-argument form that filter requires. The same pattern works with anyMatch, allMatch, and noneMatch.

When you have a collection of pairs, such as a Map, a BiPredicate aligns naturally with each entry:

Map<String, Integer> scores = Map.of("alice", 42, "bob", 17, "carol", 88); BiPredicate<String, Integer> highScore = (name, score) -> score >= 50; List<String> topScorers = scores.entrySet().stream() .filter(entry -> highScore.test(entry.getKey(), entry.getValue())) .map(Map.Entry::getKey) .toList();

Here each Map.Entry supplies both the key and the value, so the two-parameter test call reads naturally.

Practical Validation Patterns

BiPredicate is a clean fit for validation rules that compare two fields or two values. Defining the rules as named predicates keeps the validation logic in one place and prevents the same checks from being duplicated across request handlers or service methods.

public class PairValidator { private static final BiPredicate<String, String> bothNonEmpty = (first, second) -> first != null && !first.isBlank() && second != null && !second.isBlank(); private static final BiPredicate<String, String> differentValues = (first, second) -> !first.equals(second); public boolean isValidPair(String first, String second) { return bothNonEmpty.and(differentValues).test(first, second); } }

Both predicates share the same type parameters, so and composes them without friction. The isValidPair method reads as a single declarative check rather than a sequence of if statements.

A similar pattern works for range checks or boundary validation:

BiPredicate<Integer, Integer> isWithinBounds = (value, bound) -> value >= 0 && value < bound; boolean inBounds = isWithinBounds.test(5, 10); // true

When the two arguments represent different roles — say, an entity and a configuration value — the BiPredicate signature documents that relationship explicitly.

Common Mistakes and Edge Cases

One frequent mistake is assuming and and or mutate the original predicate. They do not. Each call returns a new instance, and discarding the result leaves the original unchanged:

BiPredicate<Integer, Integer> positive = (a, b) -> a > 0 && b > 0; BiPredicate<Integer, Integer> negative = (a, b) -> a < 0 && b < 0; positive.or(negative); // result discarded; positive is unchanged

Another issue is null handling. test has no built-in null protection. A lambda like (a, b) -> a.equals(b) throws NullPointerException when a is null. If nulls are possible, guard explicitly:

BiPredicate<String, String> safeEquals = (a, b) -> a == null ? b == null : a.equals(b);

Type erasure is also worth remembering. BiPredicate<String, String> and BiPredicate<Integer, Integer> are the same class at runtime, so you cannot overload a method solely on BiPredicate type parameters.

Performance and Runtime Considerations

A BiPredicate instance is a small object. Creating a lambda allocates an instance, but the JVM can often eliminate that allocation through escape analysis, and the JIT compiler typically inlines the test call when the predicate is invoked repeatedly in a hot path.

Composing predicates with and and or introduces wrapper objects. Each call to and returns a new BiPredicate that delegates to both operands. In a tight loop, rebuilding the composed predicate on every iteration adds avoidable allocation pressure. Assign the composed instance to a local variable or field once and reuse it:

// Rebuilds the composed predicate on every iteration for (int i = 0; i < 100_000; i++) { boolean ok = positive.and(negative).test(x, y); } // Builds once, reuses BiPredicate<Integer, Integer> combined = positive.and(negative); for (int i = 0; i < 100_000; i++) { boolean ok = combined.test(x, y); }

The difference is usually small, but in latency-sensitive code paths it is worth avoiding repeated composition. The same principle applies to Predicate, BiFunction, and other functional interfaces: construct the composed form once when the inputs are stable.

When BiPredicate Is the Right Choice

Use BiPredicate when a boolean condition naturally depends on two values and you want to pass that condition around as a single unit. It fits validation rules that compare two fields, filtering logic that needs an external parameter, reusable conditions in a rules engine, and callback-style APIs that accept a two-argument check.

When the condition depends on more than two values, BiPredicate is not the right tool. There is no TriPredicate in java.util.function. For three or more inputs, write a custom functional interface or use a Predicate over a small record that groups the values.

When the operation produces a value other than boolean, use BiFunction<T, U, R> instead. BiPredicate is specifically for boolean outcomes.

When the two arguments are really one object and one configuration value that stays fixed for the duration of the operation, a Predicate<T> that closes over the configuration is often simpler:

// BiPredicate version BiPredicate<String, Integer> longerThan = (s, n) -> s.length() > n; // Predicate version with closure int minLength = 5; Predicate<String> longerThanMin = s -> s.length() > minLength;

The Predicate version is shorter and reads more naturally when the second value is constant. Use BiPredicate when both values vary independently and the condition should be reusable across different pairs.

BiPredicate with Custom Functional Interfaces

BiPredicate.test does not declare any checked exceptions, so a lambda that throws one will not compile. When your two-argument check must throw a checked exception, define your own functional interface:

@FunctionalInterface public interface ThrowingBiPredicate<T, U> { boolean test(T t, U u) throws Exception; }

Then adapt it to BiPredicate where the exception is handled:

ThrowingBiPredicate<String, String> throwsOnInvalid = (a, b) -> { if (a == null) { throw new IllegalArgumentException("a is null"); } return a.contains(b); }; BiPredicate<String, String> safe = (a, b) -> { try { return throwsOnInvalid.test(a, b); } catch (Exception e) { return false; } };

This keeps the throwing variant separate from the non-throwing API and makes the failure-to-boolean conversion explicit at the adaptation point. The same approach extends to Predicate, BiFunction, and Consumer when checked exceptions are involved, so the pattern is worth internalizing even if BiPredicate itself does not throw.

java bipredicate: Practical Usage and Code Examples | RYUSLOG DEV