Part 4 of 16

Functional Interfaces: Predicate, Function, Supplier, Consumer, and More

What Is a Functional Interface?

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.


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

InterfaceSignaturePrimitive variants
Predicate<T>T → booleanIntPredicate, LongPredicate, DoublePredicate
BiPredicate<T,U>(T,U) → boolean
Function<T,R>T → RIntFunction<R>, ToIntFunction<T>, IntToLongFunction, …
BiFunction<T,U,R>(T,U) → RToIntBiFunction<T,U>, …
UnaryOperator<T>T → TIntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator
BinaryOperator<T>(T,T) → TIntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
Consumer<T>T → voidIntConsumer, LongConsumer, DoubleConsumer
BiConsumer<T,U>(T,U) → voidObjIntConsumer<T>, ObjLongConsumer<T>, ObjDoubleConsumer<T>
Supplier<T>() → TIntSupplier, LongSupplier, DoubleSupplier, BooleanSupplier

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"

Summary

InterfaceWhen to use
Predicate<T>Filtering, conditional checks, validation
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 8Java 11Java 17Java 21