It is Tuesday afternoon and you have just invented Pair<Order, String> so a one-arg Function can see both the order and the coupon. Or a tiny record whose only job is to unwrap into a Predicate test. The helper needed two arguments. Function, Predicate, and Consumer took one.

BiFunction, BiPredicate, and BiConsumer are those three shapes with a second argument. No pair type required.

Two arguments. No Pair type. The series hub owns the glossary; here we only care about apply / test / accept, the composition defaults, and the Map and Stream slots that already pass two values.

The pair you used to invent

A pre-lambda helper that needed an order and a rate usually grew a holder:

record OrderAndRate(Order order, BigDecimal rate) {}

Function<OrderAndRate, BigDecimal> payable = pair ->
        pair.order().total().multiply(pair.rate());
BigDecimal due = payable.apply(new OrderAndRate(order, new BigDecimal("0.90")));

The holder exists so a one-arg Function has something to unwrap. The same intent with a BiFunction is two parameters and a JDK slot:

BiFunction<Order, BigDecimal, BigDecimal> payable =
        (order, rate) -> order.total().multiply(rate);
BigDecimal due = payable.apply(order, new BigDecimal("0.90"));

Same result. The type is BiFunction<Order, BigDecimal, BigDecimal> whether you name it or let Map.merge / compute supply it.

Three contracts, same lab

The lab is still Order / LineItem / DiscountPolicy / PaymentGateway. DiscountPolicy stays a one-arg named seam. Two-arg work against an Order and a Map<String, Order> uses the package types.

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);

    default <V> BiFunction<T, U, V> andThen(Function<? super R, ? extends V> after) { … }
}

@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() { … }
}

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);

    default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) { … }
}

The methods you implement are apply, test, and accept. The rest are defaults. There is no BiSupplier and no compose on any of these three.

TypeMethodJob
BiFunction<T,U,R>apply(t, u)Two values in, a result out
BiPredicate<T,U>test(t, u)Two values in, yes or no
BiConsumer<T,U>accept(t, u)Two values in, a side effect

Against the map of orders:

BiFunction<Order, BigDecimal, BigDecimal> payable =
        (order, rate) -> order.total().multiply(rate);

BiPredicate<Order, BigDecimal> atLeast = (order, min) ->
        order.total().compareTo(min) >= 0;

BiConsumer<String, Order> trail = (id, order) ->
        audit.record(id, "PAID");

boolean billable = atLeast.test(order, new BigDecimal("10"));
byId.forEach(trail);

PaymentGateway.charge that needs an order and an amount is a BiConsumer adapter (gateway::charge) onto the named collaborator. It is not a replacement for that seam — same rule the Consumer post already made for one argument.

BinaryOperator is BiFunction<T,T,T>: same type twice in, same type out. BigDecimal::add in a merge slot is that restriction. This post does not retell operators.

Where the JDK already asks for two arguments

You do not invent a pair type for these slots. You fill them.

APISlotTypical lambda
Map.mergeBiFunction<? super V, ? super V, ? extends V>BigDecimal::add
Map.computeBiFunction<? super K, ? super V, ? extends V>(id, current) -> …
Map.computeIfPresentBiFunction<? super K, ? super V, ? extends V>(id, order) -> refresh(order)
Map.computeIfAbsentFunction<? super K, ? extends V>this::loadFromStore — not Bi*
Map.replaceAllBiFunction<? super K, ? super V, ? extends V>(id, order) -> deactivate(order)
Map.forEachBiConsumer<? super K, ? super V>(id, order) -> log.info("{}", id)
Stream.reduce (3-arg)BiFunction accumulator, BinaryOperator combiner(sum, order) -> sum.add(order.total())

computeIfAbsent is in the table because people reach for a BiFunction and the slot is a Function. List.replaceAll is a UnaryOperator; Map.replaceAll is the two-arg cousin.

Map<String, BigDecimal> totals = new HashMap<>();
for (Order order : orders) {
    totals.merge(order.customerEmail(), order.total(), BigDecimal::add);
}

byId.computeIfAbsent(orderId, this::loadFromStore);

byId.replaceAll((id, order) ->
        order.active() ? order : archived(order));

byId.forEach((id, order) -> log.info("{} {}", id, order.total()));

BigDecimal teamTotal = orders.stream()
        .reduce(BigDecimal.ZERO,
                (sum, order) -> sum.add(order.total()),
                BigDecimal::add);

The three-arg reduce accumulator sees a partial result and an element (BiFunction<U, T, U>). The combiner merges two partials and is a BinaryOperator<U>. Sequential streams may never call the combiner; parallel ones will.

Composition on each type

BiFunction.andThen takes a unary Function, not another BiFunction. The two-arg work runs first; then after sees the result.

BiFunction<Order, BigDecimal, BigDecimal> payable =
        (order, rate) -> order.total().multiply(rate);
Function<BigDecimal, String> asMoney = BigDecimal::toPlainString;

BiFunction<Order, BigDecimal, String> label = payable.andThen(asMoney);
String due = label.apply(order, new BigDecimal("0.90"));

A method reference has no andThen until you assign it. Assign, or cast — same rule as Consumer.andThen and Predicate.negate.

BiPredicate composes like Predicate: and / or short-circuit; negate flips. There is no BiPredicate.not and no isEqual.

BiPredicate<Order, String> sameCustomer =
        (order, email) -> email.equals(order.customerEmail());
BiPredicate<Order, String> ready =
        sameCustomer.and((order, email) -> order.active());
BiPredicate<Order, String> skip = ready.negate();

BiConsumer.andThen runs two effects on the same (t, u). There is no output to thread. If the first throws, the second does not run.

BiConsumer<String, Order> notify = (id, order) -> notifier.notifyPaid(order);
BiConsumer<String, Order> trail = (id, order) -> audit.record(id, "PAID");
byId.forEach(notify.andThen(trail));

Pitfalls

Map.merge and null. The value you pass in must be non-null or you get NullPointerException before the remapping function runs. If the key is absent (or mapped to null), merge puts that value and does not call the BiFunction. If the key is present, the function sees (oldValue, incoming). A null remapper return deletes the entry. That is a delete, not a “store null.”

compute vs computeIfAbsent. compute always calls the BiFunction with the key and the current value — null when the key is missing. A null return removes the mapping. computeIfAbsent takes a Function<K,V> and runs only when the key is absent or mapped to null; a null return means “do not insert.” Do not write (id, current) -> current == null ? load(id) : current on compute when computeIfAbsent is the slot.

Checked exceptions. apply / test / accept do not declare them. Wrap at the boundary. Empty catch inside a merge function is how production maps swallow failures — same wall as the one-arg family. The policy is Exceptions.

Named seam vs package type. A two-arg discount other people will implement still wants a verb (payable(Order, String coupon) on a type you own). BiFunction<Order, String, BigDecimal> compiles. Use it when merge / compute / forEach already declared the slot.

Cheat sheet

SAM          BiFunction  R apply(T t, U u)
             BiPredicate boolean test(T t, U u)
             BiConsumer  void accept(T t, U u)

Defaults     BiFunction.andThen(Function)     two-arg first, then unary
             BiPredicate and / or / negate    short-circuit; no not()
             BiConsumer.andThen               both see the same (t, u)

JDK slots    Map.merge / compute / computeIfPresent / replaceAll
             Map.forEach                      Stream.reduce accumulator
             computeIfAbsent                  Function, not BiFunction
             BinaryOperator                   BiFunction<T,T,T>  (operators post)

Do           fill merge / forEach with Bi*    name a seam when the verb is yours
Don't        Pair just to call Function       merge remapper that returns null by accident
             compute when computeIfAbsent     empty-catch inside apply / test / accept

Do:

  • Fill merge / compute / replaceAll / Map.forEach with the Bi* type the slot already named.
  • Compose with andThen / and / or on a typed variable, not on a bare method reference.
  • Use BinaryOperator when both arguments and the result are the same type.

Don’t:

  • Invent OrderCouponPair so a one-arg Function can unwrap two values.
  • Treat a null from merge / compute as “store empty”; it deletes the entry.
  • Reach for compute when the work is “load if missing” — that is computeIfAbsent.

Wrap-up

BiFunction, BiPredicate, and BiConsumer are the two-argument forms of Function, Predicate, and Consumer. You meet them first on Map: merge, compute, replaceAll, forEach. A pair type was never the API. The next grid skips boxing on hot numeric paths.

Next optional step in the series Skip boxing on hot numeric paths with the primitive SAM grid. Primitive Functional Interfaces: Skip the Box on the Hot Path