Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Functional Interfaces: Function, Predicate, Supplier, Consumer

The four core shapes and how to derive the other thirty-nine, why primitive specialisations exist, compose versus andThen, and the checked-exception problem with a clean workaround.

Beginner7 min readUpdated
On this page

The java.util.function package has forty-three interfaces, which sounds like a memorisation task and is not. There are four shapes; everything else is a variation on arity or primitive type.

Key Takeaways

  • Four core shapes: Supplier (no in, one out), Consumer (one in, no out), Function (one in, one out), Predicate (one in, boolean out).
  • The rest are arity variants (BiFunction), same-type variants (UnaryOperator) and primitive specialisations (IntPredicate).
  • Primitive variants exist purely to avoid boxing, which dominates cost in numeric streams.
  • f.andThen(g) runs f first; f.compose(g) runs g first.
  • Built-in interfaces cannot throw checked exceptions — that friction shapes a lot of stream code.

The four shapes

InterfaceSignatureReads as
Supplier<T>T get()"give me one"
Consumer<T>void accept(T t)"take this"
Function<T,R>R apply(T t)"turn this into that"
Predicate<T>boolean test(T t)"is this true of it?"
all four, in one pipeline
Supplier<List<String>>      newList   = ArrayList::new;
Predicate<String>           isActive  = s -> s.startsWith("ACTIVE");
Function<String, Integer>   toLength  = String::length;
Consumer<Integer>           print     = System.out::println;
 
lines.stream()
     .filter(isActive)          // Predicate
     .map(toLength)             // Function
     .forEach(print);           // Consumer

From those four, the whole package follows by three rules:

Arity. Prefix Bi for two arguments: BiFunction<T,U,R>, BiConsumer<T,U>, BiPredicate<T,U>. There is no BiSupplier — a supplier takes nothing, so arity does not apply.

Same type in and out. UnaryOperator<T> is a Function<T,T>; BinaryOperator<T> is a BiFunction<T,T,T>. They exist so signatures read better: List.replaceAll takes a UnaryOperator<E>, and Stream.reduce takes a BinaryOperator<T>.

Primitives. IntPredicate, LongFunction<R>, ToIntFunction<T>, IntToDoubleFunction, ObjIntConsumer<T>, and so on. Read the name as a description of the signature and none of them need memorising.

Why the primitive variants exist

the cost of boxing
// Boxes every element: int -> Integer -> int, plus an Integer allocation each.
Function<Integer, Integer> doubler = i -> i * 2;
IntStream.range(0, 10_000_000).boxed().map(doubler).sum();
 
// No boxing at all.
IntUnaryOperator fast = i -> i * 2;
IntStream.range(0, 10_000_000).map(fast).sum();

The second form is typically several times faster and allocates nothing. Integers from -128 to 127 come from a cache, so small values are cheaper, but a stream over real data escapes that range immediately.

This is why IntStream, LongStream and DoubleStream exist as separate types, and why mapToInt(Order::quantity).sum() is the right way to total a numeric field rather than map(Order::quantity).reduce(0, Integer::sum).

Composition

andThen and compose
Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;
 
trim.andThen(upper).apply("  abc  ");   // trim first, then upper -> "ABC"
trim.compose(upper).apply("  abc  ");   // upper first, then trim -> "ABC"
 
// The direction matters when the types differ:
Function<String, Integer> length = String::length;
Function<Integer, String> label  = n -> "len=" + n;
 
length.andThen(label).apply("hello");   // "len=5"
label.compose(length).apply("hello");   // "len=5" — same thing, read right to left

andThen reads left to right and is almost always the clearer choice. compose mirrors the mathematical f o g notation.

Predicates compose with boolean logic, and this is where composition earns its place in real code:

composing a filter from rules
Predicate<Order> isLarge   = o -> o.total().compareTo(THRESHOLD) > 0;
Predicate<Order> isDomestic = o -> "GB".equals(o.country());
Predicate<Order> isFlagged  = o -> o.riskScore() > 80;
 
Predicate<Order> needsReview = isLarge.and(isDomestic.negate()).or(isFlagged);
 
orders.stream().filter(needsReview).toList();

Each rule is separately testable and separately named. Predicate.not(...) (Java 11) is often clearer than .negate(), particularly with method references: filter(Predicate.not(String::isBlank)).

Consumers chain too: logIt.andThen(saveIt).accept(event) runs both in order.

@FunctionalInterface

Validator.java
@FunctionalInterface
public interface Validator<T> {
    List<String> validate(T target);                    // the single abstract method
 
    // Default and static methods do not count towards the limit.
    default Validator<T> and(Validator<T> other) {
        return target -> {
            List<String> errors = new ArrayList<>(this.validate(target));
            errors.addAll(other.validate(target));
            return errors;
        };
    }
 
    static <T> Validator<T> alwaysValid() { return t -> List.of(); }
}

The annotation is optional — any interface with one abstract method can be a lambda target. What it buys you is a compile error if someone later adds a second abstract method, instead of a mysterious failure at every call site that used a lambda. Put it on any interface you intend to be used that way.

Methods that override Objectequals, hashCode, toString — are excluded from the count. That is how Comparator remains functional despite declaring equals.

The checked exception problem

the friction
List<String> contents = paths.stream()
        .map(Files::readString)     // does not compile: readString throws IOException
        .toList();

Function.apply declares no checked exceptions, so nothing that throws one can be a Function. The three ways out:

option 1 — wrap inline (fine once, noisy at scale)
.map(p -> {
    try { return Files.readString(p); }
    catch (IOException e) { throw new UncheckedIOException(e); }
})
option 2 — a throwing interface of your own
@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;
}
option 3 — an adapter, the one worth keeping
public final class Unchecked {
    private Unchecked() { }
 
    public static <T, R> Function<T, R> function(ThrowingFunction<T, R, ?> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (Exception e) {
                // Preserve the interrupt flag rather than swallowing it.
                if (e instanceof InterruptedException) Thread.currentThread().interrupt();
                throw e instanceof RuntimeException re ? re : new RuntimeException(e);
            }
        };
    }
}
 
// Usage stays readable:
List<String> contents = paths.stream().map(Unchecked.function(Files::readString)).toList();

Where they show up in real APIs

Recognising the shapes in signatures you already use is what makes the vocabulary stick, and it is a good source of concrete examples in an interview.

Map.computeIfAbsent(key, Function<K, V>) takes a function from key to value, which is why the lambda receives the key even when you ignore it. Map.merge(key, value, BinaryOperator<V>) takes a combiner of two values of the same type. Optional.orElseGet(Supplier<T>) takes a supplier precisely so the fallback is deferred, and Optional.filter(Predicate<T>) takes a predicate. List.removeIf takes a Predicate, List.replaceAll a UnaryOperator, and Iterable.forEach a Consumer.

Spring uses the same vocabulary throughout. JdbcTemplate.query takes a RowMapper, which is a BiFunction in all but name. RestClient exception handlers take a BiConsumer of request and response. TransactionTemplate.execute takes a callback that is a Function from status to result. Once you read a signature as "this parameter is a producer of T" rather than as an unfamiliar interface name, unfamiliar APIs get much faster to learn.

The design lesson underneath is worth stating too. Before Java 8, an API that wanted to accept behaviour had to declare its own single-method interface, and every one of them was incompatible with every other. Standardising on four shapes means a lambda written for one library composes with another, and it is why Comparator, Runnable and Callable — all predating Java 8 — became lambda targets without any change.

Writing your own

Declare a custom functional interface when the built-in one would be technically correct but unreadable, or when you need a checked exception in the signature. A domain name carries meaning that BiFunction<Order, Customer, Decision> does not:

a named shape
@FunctionalInterface
public interface PricingRule {
    Money apply(Order order, Customer customer);
}

Both compile to the same thing at runtime. The named version documents intent at every call site, appears usefully in stack traces, and can gain default methods later — PricingRule.andThen, PricingRule.onlyFor — without breaking anyone.

The counter-argument is real: a custom interface cannot be passed to anything expecting the standard one, so a value that flows into stream operations should stay standard. The practical split is to use domain interfaces at architectural boundaries and standard ones inside implementations.

What gets asked

Usually three things. Name the four core interfaces and their method signatures. Explain why IntFunction exists when Function<Integer, R> already does — the boxing answer. And describe how you would call a method that throws IOException from inside a map. That third question separates people who have written stream code in anger from people who have read about it.

A good closing move is to point at a signature in the JDK and read it aloud as a shape. "Collectors. groupingBy takes a Function from element to key, and a downstream Collector" demonstrates the vocabulary is actually working for you rather than being recited.

Frequently Asked Questions

What makes an interface functional?
Exactly one abstract method. Default methods, static methods, private methods and any public method that overrides one of java.lang.Object — equals, hashCode, toString — do not count towards the total. The @FunctionalInterface annotation is optional; it only asks the compiler to enforce the rule so a later edit cannot silently break every lambda that implements the interface.
Why are there so many functional interfaces in java.util.function?
Forty-three, and almost all of them are primitive specialisations that exist to avoid boxing. Function of Integer to Integer boxes both the argument and the result on every call; IntUnaryOperator does neither. In a stream over millions of primitives that difference is the whole cost, so the JDK provides an unboxed variant for int, long and double.
How do I throw a checked exception from a lambda?
You cannot, because the built-in functional interfaces declare no checked exceptions. The three options are: catch inside the lambda and rethrow unchecked, usually wrapping in UncheckedIOException; declare your own functional interface that throws; or write a small static wrapper that converts a throwing lambda into a standard one. The last is the cleanest when it appears more than twice in a codebase.

Related tutorials