Functional Interfaces: Predicate, Function, Supplier, Consumer, and More
What Is a Functional Interface?
Functional interfaces are the type system bridge that makes lambda expressions work in Java 8. Without them, the compiler would have no way to know what type to assign to a lambda. Once you understand how the 43 built-in interfaces in java.util.function are organised — and when to write your own — you will find that the same patterns (compose, chain, validate, transform) appear everywhere in a Java 8 codebase.
A functional interface is an interface with exactly one abstract method (SAM — Single Abstract Method). This is the type the compiler targets when you write a lambda expression.
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2); // the single abstract method
// default and static methods are allowed
}
The @FunctionalInterface annotation is optional but highly recommended — it makes the intent explicit and causes the compiler to fail if you accidentally add a second abstract method.
All existing single-abstract-method interfaces from the JDK work with lambdas automatically:
Runnable r = () -> System.out.println("run");
Callable<Integer> c = () -> 42;
Comparator<String> cmp = (a, b) -> a.compareTo(b);
The java.util.function Package
Java 8 introduced java.util.function with 43 functional interfaces covering every common function shape. They fall into five categories.
Predicate<T>: T → boolean
Tests a condition on a value. Used for filtering.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("Hello")); // false
System.out.println(isLong.test("Welcome")); // true
// Stream filtering
List<String> longNames = names.stream()
.filter(isLong)
.collect(Collectors.toList());
Composition methods:
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> isLong = s -> s.length() > 5;
// AND
Predicate<String> longAndA = startsWithA.and(isLong);
// OR
Predicate<String> longOrA = startsWithA.or(isLong);
// NOT
Predicate<String> doesNotStartWithA = startsWithA.negate();
// Static helper — isEqual
Predicate<String> isAlice = Predicate.isEqual("Alice");
Primitive specialisations: IntPredicate, LongPredicate, DoublePredicate — avoid boxing overhead in numeric pipelines.
BiPredicate<T, U>: (T, U) → boolean
BiPredicate is the two-argument version of Predicate. Use it when your condition depends on two separate values rather than one.
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
}
Basic example — does a name start with a given letter?
BiPredicate<String, Character> startsWithLetter =
(name, letter) -> name.charAt(0) == letter;
System.out.println(startsWithLetter.test("Alice", 'A')); // true
System.out.println(startsWithLetter.test("Bob", 'A')); // false
Real-world example — does an employee earn above a given threshold?
BiPredicate<Employee, Double> earnsMoreThan =
(employee, threshold) -> employee.getSalary() > threshold;
// Reuse the same BiPredicate with different thresholds
employees.stream()
.filter(e -> earnsMoreThan.test(e, 90_000.0))
.forEach(e -> System.out.println(e.getName() + " earns over £90k"));
employees.stream()
.filter(e -> earnsMoreThan.test(e, 50_000.0))
.forEach(e -> System.out.println(e.getName() + " earns over £50k"));
Composition — and(), or(), negate() work the same as Predicate:
BiPredicate<String, Integer> longName = (name, age) -> name.length() > 5;
BiPredicate<String, Integer> isAdult = (name, age) -> age >= 18;
BiPredicate<String, Integer> longNameAndAdult = longName.and(isAdult);
BiPredicate<String, Integer> eitherCondition = longName.or(isAdult);
BiPredicate<String, Integer> shortName = longName.negate();
System.out.println(longNameAndAdult.test("Charlie", 25)); // true (both pass)
System.out.println(longNameAndAdult.test("Alice", 25)); // false (name too short)
System.out.println(longNameAndAdult.test("Charlie", 15)); // false (underage)
When to choose BiPredicate over Predicate:
Use Predicate<T> when you have one subject to test. Use BiPredicate<T, U> when the test outcome depends on two inputs — typically when you want to reuse the same condition with different comparison values (thresholds, target values, reference objects) without creating a new lambda for each value.
Function<T, R>: T → R
Transforms a value of type T into a value of type R. Used for mapping.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
Function<String, Integer> length = String::length;
Function<String, String> upper = String::toUpperCase;
System.out.println(length.apply("Hello")); // 5
System.out.println(upper.apply("Hello")); // HELLO
// Stream mapping
List<Integer> lengths = names.stream()
.map(length)
.collect(Collectors.toList());
Composition methods:
Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, String> toStr = x -> "Value: " + x;
// andThen: apply times2 first, then toStr
Function<Integer, String> times2ThenStr = times2.andThen(toStr);
System.out.println(times2ThenStr.apply(5)); // "Value: 10"
// compose: apply toStr of the inner function to the input, then apply times2
// i.e., compose(g) = this(g(x))
Function<Integer, Integer> strLenThenTimes2 =
times2.compose((String s) -> s.length()); // times2(s.length())
identity() — returns a function that returns its input:
Function<String, String> identity = Function.identity();
// equivalent to: s -> s
Primitive specialisations: IntFunction<R>, ToIntFunction<T>, IntToLongFunction, etc.
Consumer<T>: T → void
Performs a side-effecting operation on a value without returning anything.
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
Consumer<String> print = System.out::println;
Consumer<String> log = s -> logger.info("Processed: {}", s);
// Chaining with andThen
Consumer<String> printAndLog = print.andThen(log);
printAndLog.accept("Alice"); // prints, then logs
// Used in forEach
names.forEach(print);
map.forEach((k, v) -> System.out.println(k + "=" + v));
Variants: BiConsumer<T,U>, IntConsumer, LongConsumer, DoubleConsumer, ObjIntConsumer<T>.
Supplier<T>: () → T
Produces a value without taking any input. Used for lazy initialisation and factory patterns.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Supplier<LocalDate> today = LocalDate::now;
Supplier<List<String>> newList = ArrayList::new;
System.out.println(today.get()); // current date, evaluated lazily
// Optional.orElseGet uses Supplier for lazy fallback
Optional<User> user = findUser(id);
User result = user.orElseGet(() -> createDefaultUser());
// vs orElse — the default is always computed even if optional is present
User result2 = user.orElse(createDefaultUser()); // createDefaultUser() always called!
The orElse vs orElseGet distinction is one of the most common Java 8 bugs. Always prefer orElseGet when the fallback is expensive to compute.
BiFunction<T, U, R>: (T, U) → R
Two-argument version of Function.
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n); // Java 11+
// Java 8 equivalent:
BiFunction<String, Integer, String> repeatJ8 = (s, n) -> {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(s);
return sb.toString();
};
System.out.println(repeatJ8.apply("ab", 3)); // "ababab"
// Map.replaceAll uses BiFunction
map.replaceAll((key, value) -> value.toUpperCase());
UnaryOperator<T>: T → T
Specialisation of Function<T,T> where input and output are the same type. Used when transforming a value in-place.
UnaryOperator<String> trim = String::trim;
UnaryOperator<Integer> increment = n -> n + 1;
// List.replaceAll
list.replaceAll(String::toUpperCase);
// Chain with andThen / compose (inherited from Function)
UnaryOperator<String> trimAndUpper = trim.andThen(String::toUpperCase)::apply;
// Note: andThen returns Function, need ::apply to get back UnaryOperator behaviour
BinaryOperator<T>: (T, T) → T
Two-argument version of UnaryOperator. Used for reduction operations.
BinaryOperator<Integer> add = (a, b) -> a + b;
BinaryOperator<String> concat = String::concat;
// Stream.reduce
int sum = numbers.stream().reduce(0, add);
String combined = words.stream().reduce("", concat);
// Static helpers
BinaryOperator<Integer> maxOp = BinaryOperator.maxBy(Comparator.naturalOrder());
BinaryOperator<Integer> minOp = BinaryOperator.minBy(Comparator.naturalOrder());
Full Reference Table
| Interface | Signature | Primitive variants |
|---|---|---|
Predicate<T> | T → boolean | IntPredicate, LongPredicate, DoublePredicate |
BiPredicate<T,U> | (T,U) → boolean | — |
Function<T,R> | T → R | IntFunction<R>, ToIntFunction<T>, IntToLongFunction, … |
BiFunction<T,U,R> | (T,U) → R | ToIntBiFunction<T,U>, … |
UnaryOperator<T> | T → T | IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator |
BinaryOperator<T> | (T,T) → T | IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator |
Consumer<T> | T → void | IntConsumer, LongConsumer, DoubleConsumer |
BiConsumer<T,U> | (T,U) → void | ObjIntConsumer<T>, ObjLongConsumer<T>, ObjDoubleConsumer<T> |
Supplier<T> | () → T | IntSupplier, LongSupplier, DoubleSupplier, BooleanSupplier |
Primitive Specialisations: Why They Matter for Performance
Every generic functional interface in java.util.function has primitive variants — IntPredicate, LongFunction, IntBinaryOperator, and so on. These are not just naming conveniences: they exist to eliminate boxing overhead.
When you use BinaryOperator<Integer>, Java must wrap every int in an Integer object on the way in and unwrap it on the way out. Over millions of calls this adds up.
// Boxed — every call allocates Integer objects
BinaryOperator<Integer> addBoxed = (a, b) -> a + b;
int sumBoxed = IntStream.range(0, 1_000_000)
.boxed()
.reduce(0, addBoxed);
// Primitive — no boxing, no allocation
IntBinaryOperator addPrimitive = (a, b) -> a + b;
int sumPrimitive = IntStream.range(0, 1_000_000)
.reduce(0, addPrimitive);
Benchmarks on typical hardware consistently show primitive variants running ~3x faster than their boxed equivalents for numeric pipelines, because the JVM does no heap allocation and GC pressure is eliminated.
Rule of thumb: whenever your lambda operates on int, long, or double, reach for the primitive variant first:
| Instead of | Use |
|---|---|
Predicate<Integer> | IntPredicate |
Function<Integer, Integer> | IntUnaryOperator |
BinaryOperator<Integer> | IntBinaryOperator |
Function<Integer, Long> | IntToLongFunction |
Supplier<Integer> | IntSupplier |
Defining Your Own Functional Interface
The built-in interfaces cover most cases, but sometimes you need a domain-specific signature:
@FunctionalInterface
public interface ThrowingFunction<T, R> {
R apply(T t) throws Exception;
// Helper to wrap as standard Function
static <T, R> Function<T, R> wrap(ThrowingFunction<T, R> f) {
return t -> {
try { return f.apply(t); }
catch (Exception e) { throw new RuntimeException(e); }
};
}
}
// Usage
List<String> contents = paths.stream()
.map(ThrowingFunction.wrap(Files::readString))
.collect(Collectors.toList());
Custom functional interfaces are useful for:
- Adding checked exception support
- Domain-specific naming (
Validator<T>,Transformer<T, R>) - API contracts that communicate intent more clearly than
Function
Practical Patterns
Validation Pipeline
public class Validator<T> {
private final List<Predicate<T>> rules = new ArrayList<>();
public Validator<T> addRule(Predicate<T> rule) {
rules.add(rule);
return this;
}
public boolean validate(T value) {
return rules.stream().allMatch(rule -> rule.test(value));
}
}
Validator<String> passwordValidator = new Validator<String>()
.addRule(s -> s.length() >= 8)
.addRule(s -> s.chars().anyMatch(Character::isDigit))
.addRule(s -> s.chars().anyMatch(Character::isUpperCase));
System.out.println(passwordValidator.validate("Hello123")); // true
System.out.println(passwordValidator.validate("short")); // false
Factory Map
Map<String, Supplier<Animal>> animalFactory = new HashMap<>();
animalFactory.put("dog", Dog::new);
animalFactory.put("cat", Cat::new);
animalFactory.put("bird", Bird::new);
Animal pet = animalFactory
.getOrDefault("dog", () -> { throw new IllegalArgumentException("Unknown"); })
.get();
Transform Pipeline
Function<String, String> pipeline = ((Function<String, String>) String::trim)
.andThen(String::toLowerCase)
.andThen(s -> s.replaceAll("\\s+", "-"));
System.out.println(pipeline.apply(" Hello World ")); // "hello-world"
Common Mistakes
Confusing orElse with orElseGet for Supplier arguments
The orElseGet method on Optional takes a Supplier<T>, not a value. A common mistake is to pass an expensive method call as the orElse argument when orElseGet is needed:
// WRONG: createDefault() is called even when the Optional is present
User user = userOpt.orElse(createDefault());
// RIGHT: createDefault() is only called when the Optional is empty
User user = userOpt.orElseGet(this::createDefault);
This applies to any API that accepts a Supplier<T> — always prefer the lazy form when the fallback computation is non-trivial.
Using Function when Consumer is correct
// WRONG: Function expects a return value; void methods do not match
Function<String, ?> logger = s -> System.out.println(s); // compiler error
// RIGHT
Consumer<String> logger = s -> System.out.println(s);
Consumer<String> logger2 = System.out::println;
Writing primitive-oblivious Function<Integer, Integer> in numeric pipelines
// WRONG: boxes/unboxes on every call in a numeric pipeline
Function<Integer, Integer> doubler = n -> n * 2;
// RIGHT: use the primitive specialisation
IntUnaryOperator doubler = n -> n * 2;
int result = doubler.applyAsInt(5); // no boxing
Summary
| Interface | When to use |
|---|---|
Predicate<T> | Filtering, conditional checks, validation |
BiPredicate<T,U> | Filtering with two inputs (e.g. value + threshold) |
Function<T,R> | Mapping, transforming, converting |
Consumer<T> | Side effects: logging, writing, printing |
Supplier<T> | Lazy initialisation, factories, deferred values |
BiFunction<T,U,R> | Two-input transformations |
UnaryOperator<T> | In-place transformation (same type in and out) |
BinaryOperator<T> | Reduction, combining two values of the same type |
Next Step
Method References: Four Kinds and When to Use Each →
Part of the DevOps Monk Java tutorial series: Java 8 → Java 11 → Java 17 → Java 21