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.
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)runsffirst;f.compose(g)runsgfirst.- Built-in interfaces cannot throw checked exceptions — that friction shapes a lot of stream code.
The four shapes
| Interface | Signature | Reads 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?" |
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); // ConsumerFrom 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
// 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
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 leftandThen 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:
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
@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 Object — equals, hashCode, toString — are excluded from the count. That
is how Comparator remains functional despite declaring equals.
The checked exception problem
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:
.map(p -> {
try { return Files.readString(p); }
catch (IOException e) { throw new UncheckedIOException(e); }
})@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Exception> {
R apply(T t) throws E;
}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:
@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?
Why are there so many functional interfaces in java.util.function?
How do I throw a checked exception from a lambda?
Related tutorials
- Lambdas and invokedynamic: How They Really WorkWhy a lambda is not an anonymous class, what invokedynamic and LambdaMetafactory do at first call, the allocation difference between capturing and non-capturing lambdas, and what this means.
- Stream API Fundamentals: Lazy PipelinesHow a stream pipeline actually executes: why nothing runs until the terminal operation, what short-circuiting really means, stateful versus stateless operations, and why a stream is single-use.
- Java 8 Features: The Interview OverviewEvery Java 8 feature an interviewer asks about, why each was added, and the one-sentence answer for each — lambdas, streams, Optional, default methods, java.time, CompletableFuture and Metaspace.
- Collectors, groupingBy and Downstream CollectorsThe collector API in depth: multi-level groupingBy, downstream collectors, the toMap duplicate-key exception, the null-value trap, teeing and flatMapping, and writing a Collector by hand.