You need to uppercase every SKU in place, or fold line-item prices into one BigDecimal. A Function that turns Order into String is too open — the work never changes type. Tuesday afternoon, the slot already knows: same type in, same type out.

An operator is a Function (or BiFunction) with the types locked to T. The series hub owns the glossary; here we only care about UnaryOperator, BinaryOperator, identity / minBy / maxBy, and the JDK slots that already demand this tightness.

The in-place map that was already an operator

Normalizing SKUs with a throwaway mapper type is ceremony. List.replaceAll already named the slot:

List<String> skus = new ArrayList<>(
        order.items().stream().map(LineItem::sku).toList());
skus.replaceAll(String::toUpperCase);

replaceAll takes a UnaryOperator<E>. The list stays a list of String. Streams map can do the same job into a new list; replaceAll mutates this one.

Folding prices is the two-argument cousin:

BinaryOperator<BigDecimal> sum = BigDecimal::add;
BigDecimal total = order.items().stream()
        .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
        .reduce(BigDecimal.ZERO, sum);

Two BigDecimals in, one BigDecimal out. That is BinaryOperator, not a pair type you invent.

The contract

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
    static <T> UnaryOperator<T> identity() { … }
}
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
    static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) { … }
    static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) { … }
}

UnaryOperator is a Function<T,T>. It inherits apply, andThen, and compose. BinaryOperator is a BiFunction<T,T,T> — apply(t, u) and andThen come from there. The extras are static factories that keep the return type an operator, not a raw Function.

MethodTypeJob
apply(t)UnaryMap T to T
identity()Unary staticReturn the argument unchanged, as a UnaryOperator
apply(t, u)BinaryCombine two Ts into one T
minBy(cmp) / maxBy(cmp)Binary staticPick the lesser / greater argument by a Comparator

UnaryOperator.identity() is the one replaceAll will accept. Function.identity() returns a Function, and a Function is not a UnaryOperator — inheritance only goes one way.

Against the checkout lab:

UnaryOperator<String> skuUpper = String::toUpperCase;
List<LineItem> copy = new ArrayList<>(order.items());
copy.replaceAll(item -> new LineItem(
        skuUpper.apply(item.sku()),
        item.quantity(),
        item.unitPrice()));

BinaryOperator<Order> largerTotal =
        BinaryOperator.maxBy(Comparator.comparing(Order::total));
Order pick = largerTotal.apply(guest, member);

DiscountPolicy stays a named seam — Order in, BigDecimal out — so not an operator. PaymentGateway.charge is a side effect, not a combine.

Where the JDK already asks for an operator

You do not invent a SkuNormalizer for these slots. You fill them.

APISlotTypical lambda
List.replaceAllUnaryOperator<E>String::toUpperCase
Stream.reduce(op)BinaryOperator<T>BigDecimal::add
Stream.reduce(identity, op)BinaryOperator<T>BigDecimal::add
Collectors.reducingBinaryOperator<T> (and a mapper, in the three-arg form)BigDecimal::add
Stream.reduce of a whole OrderBinaryOperator<Order> via minBy / maxByBinaryOperator.maxBy(comparing(Order::total))

reduce and reducing are the everyday Streams combine. Empty stream, no identity: you get Optional. With identity, you get the identity itself:

Optional<BigDecimal> maybe = prices.stream().reduce(BigDecimal::add);
BigDecimal total = prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add);

Optional<Order> largest = orders.stream()
        .reduce(BinaryOperator.maxBy(Comparator.comparing(Order::total)));

The three-arg Collectors.reducing maps first, then combines — mapper is still a Function; the op is the operator:

BigDecimal merchandise = order.items().stream()
        .collect(Collectors.reducing(
                BigDecimal.ZERO,
                item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())),
                BigDecimal::add));

A UnaryOperator also fills any Function<T,T> slot (Stream.map of SKUs). A Function<T,T> does not fill a UnaryOperator slot. Prefer the operator when the API declared one.

Operator vs Function

Use the tighter type when the types really are the same.

Reach forWhen
Function<T,R>Input and output differ — Order::id, Order::customerEmail
UnaryOperator<T>T in, T out — replaceAll, normalize, identity in that slot
BiFunction<T,U,R>Two arguments, types may differ — next post
BinaryOperator<T>Two Ts in, one T out — reduce, minBy / maxBy, BigDecimal::add

andThen on a UnaryOperator returns a Function, not a UnaryOperator. Chain two operators you already named by writing the body (or a lambda) when the slot still wants an operator:

UnaryOperator<String> trim = String::strip;
UnaryOperator<String> upper = String::toUpperCase;
Function<String, String> both = trim.andThen(upper); // widened

skus.replaceAll(sku -> upper.apply(trim.apply(sku)));

The operator is the default when the JDK slot already declared it. Function remains the default when R is a different type, including a domain seam like DiscountPolicy.

Pitfalls

Function.identity() in replaceAll. It does not compile. The slot wants UnaryOperator. Use UnaryOperator.identity(), or x -> x.

Immutable lists. replaceAll mutates. List.of throws UnsupportedOperationException. Copy into an ArrayList first, including when the list came from order.items().

Non-associative reduce. BigDecimal::add is fine. (a, b) -> a.subtract(b) is not a parallel-safe combiner. Sequential accident, parallel bug. Streams already teach this; the type does not save you.

Nulls in minBy / maxBy. The comparator sees both arguments. A null Order or a null total is an NPE, not an empty Optional.

Mutating the argument. item -> { item.setSku(item.sku().toUpperCase()); return item; } is an in-place rewrite wearing an operator. With records you return a new LineItem. That is the honest writing.

Checked exceptions. apply does not declare them. Same boundary rule as Function — wrap or preprocess; do not empty-catch inside the operator.

Cheat sheet

Unary        Function<T,T>     apply(t)     identity() → UnaryOperator
Binary       BiFunction<T,T,T> apply(t, u)  minBy / maxBy(Comparator)

JDK slots    List.replaceAll              UnaryOperator
             Stream.reduce                BinaryOperator
             Collectors.reducing          BinaryOperator (+ mapper)

Do           String::toUpperCase in replaceAll
             BigDecimal::add / maxBy(comparing(Order::total)) in reduce
Don't        Function.identity() in replaceAll
             Function<T,T> variable passed into a UnaryOperator slot
             subtract as a parallel combiner

Do:

  • Fill replaceAll / reduce / reducing with an operator (or a method reference that matches it).
  • Use UnaryOperator.identity() when the slot requires an operator and you have nothing to change.
  • Use minBy / maxBy when “pick one of these two Ts” is the combine.

Don’t:

  • Pass a Function<T,T> into replaceAll and expect it to adapt.
  • Call replaceAll on an immutable list.
  • Pretend DiscountPolicy is an operator — Order in, BigDecimal out is a Function.

Wrap-up

UnaryOperator<T> is Function<T,T>. BinaryOperator<T> is BiFunction<T,T,T>. Reach for them when the types really are the same: replaceAll, reduce, Collectors.reducing, minBy / maxBy. Use Function when R is a different type. Two arguments that are not the same T — or a value plus a key — are the next family.

Next optional step in the series Two arguments without inventing a pair type. Bi-Arity: Two Arguments Without a Pair Type