Java Functional Interfaces: Function, Predicate, Consumer, and Composition

A functional interface has exactly one abstract method and can be implemented with a lambda or method reference. Java 8 introduced the java.util.function package with 43 built-in functional interfaces covering the most common patterns. Understanding them, and their composition methods, is essential for writing expressive, composable Java code.

Contents

Core Functional Interface Types

The java.util.function package organizes its interfaces around four fundamental shapes. Function<T, R> transforms an input of type T into an output of type R: it is the general transformation type. Predicate<T> tests a condition and returns a primitive boolean, used everywhere filtering is needed. Consumer<T> accepts a value and produces no result: it models side-effectful operations like printing or writing. Supplier<T> takes no arguments and produces a value, used for lazy evaluation and factory patterns. The Bi- variants (BiFunction, BiPredicate, BiConsumer) handle two-argument cases. UnaryOperator<T> and BinaryOperator<T> are specializations of Function where input and output types are the same. Primitive specializations like IntFunction, ToIntFunction, and IntUnaryOperator avoid autoboxing overhead in performance-sensitive code.

Java
import java.util.function.*;

// Function<T, R> — takes T, returns R
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

// BiFunction<T, U, R> — takes two args, returns R
BiFunction<String, Integer, String> repeat =
    (s, n) -> s.repeat(n);
System.out.println(repeat.apply("ab", 3)); // ababab

// Predicate<T> — takes T, returns boolean
Predicate<String> isBlank = String::isBlank;
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isBlank.test("  ")); // true
System.out.println(isEven.test(4));      // true

// Supplier<T> — takes nothing, returns T (lazy producer)
Supplier<List<String>> listFactory = ArrayList::new;
List<String> list = listFactory.get();

// Consumer<T> — takes T, returns void
Consumer<String> print = System.out::println;
print.accept("Hello"); // prints Hello

// BiConsumer<T, U>
BiConsumer<String, Integer> printRepeat =
    (s, n) -> System.out.println(s.repeat(n));

// UnaryOperator<T> — special Function where T and R are the same type
UnaryOperator<String> trim = String::trim;

// BinaryOperator<T> — BiFunction where all types are the same
BinaryOperator<Integer> add = Integer::sum;
System.out.println(add.apply(3, 4)); // 7

// Primitive specializations — avoid boxing overhead
IntFunction<String>   intToStr  = Integer::toString;
ToIntFunction<String> strToLen  = String::length;
IntUnaryOperator       doubleIt  = x -> x * 2;
IntBinaryOperator      multiply  = (a, b) -> a * b;
IntSupplier            randomInt = () -> 42;
IntConsumer            printInt  = System.out::println;
IntPredicate           positive  = x -> x > 0;

Function Composition

Function provides two composition methods that let you build transformation pipelines from small, reusable pieces. f.andThen(g) produces a new function that applies f first and then feeds the result to g, reading left to right mirrors execution order, making pipelines easy to reason about. f.compose(g) reverses this: it applies g first and then f, so f.compose(g) is mathematically equivalent to the function composition f(g(x)). Function.identity() returns the input unchanged and serves as the identity element for reduce-based dynamic pipeline construction. These composition methods return a new Function without modifying the originals, so individual steps remain reusable and testable in isolation.

Java
Function<String, String> trim   = String::trim;
Function<String, String> upper  = String::toUpperCase;
Function<String, Integer> length = String::length;

// andThen — apply this, then apply after
// trim.andThen(upper) is equivalent to: s -> upper.apply(trim.apply(s))
Function<String, String> trimThenUpper = trim.andThen(upper);
System.out.println(trimThenUpper.apply("  hello  ")); // HELLO

// compose — apply before first, then apply this
// upper.compose(trim) is equivalent to: s -> upper.apply(trim.apply(s))
Function<String, String> sameResult = upper.compose(trim);
System.out.println(sameResult.apply("  hello  ")); // HELLO

// Chain multiple steps
Function<String, Integer> pipeline = trim
    .andThen(upper)
    .andThen(length);
System.out.println(pipeline.apply("  hello  ")); // 5

// Function.identity() — returns the input unchanged
Function<String, String> identity = Function.identity();

// Compose a list of functions dynamically
List<Function<String, String>> transforms = List.of(
    String::trim, String::toUpperCase, s -> s + "!");
Function<String, String> combined = transforms.stream()
    .reduce(Function.identity(), Function::andThen);
System.out.println(combined.apply("  hello  ")); // HELLO!

// UnaryOperator composition
UnaryOperator<Integer> double_  = x -> x * 2;
UnaryOperator<Integer> addTen   = x -> x + 10;
Function<Integer, Integer> doubleThenAdd = double_.andThen(addTen);
System.out.println(doubleThenAdd.apply(5)); // 20

Predicate Composition

Predicate has three logical composition methods: and(), or(), and negate(). Both and() and or() are short-circuit: and() skips the second predicate when the first returns false, and or() skips it when the first returns true. The practical benefit is that you can define small, single-purpose predicates (notBlank, isShort, hasDigit) and combine them at the call site into precise filter expressions without writing a new lambda for every combination. Predicate.not() (Java 11+) is a static factory that negates a method reference cleanly, avoiding the awkward cast required when calling .negate() on a method reference directly.

Java
Predicate<String> notBlank   = Predicate.not(String::isBlank);
Predicate<String> shortStr   = s -> s.length() < 10;
Predicate<String> hasDigit   = s -> s.chars().anyMatch(Character::isDigit);

// and — logical AND (short-circuits)
Predicate<String> validInput = notBlank.and(shortStr).and(hasDigit);
System.out.println(validInput.test("abc123"));  // true
System.out.println(validInput.test("abc"));     // false (no digit)

// or — logical OR (short-circuits)
Predicate<String> lenient = shortStr.or(hasDigit);
System.out.println(lenient.test("verylongstring123")); // true (has digit)

// negate — logical NOT
Predicate<String> longOrBlank = shortStr.negate().or(String::isBlank);

// Predicate.not() — static convenience (Java 11+)
List<String> words = List.of("", "hello", "  ", "world");
words.stream()
    .filter(Predicate.not(String::isBlank))
    .forEach(System.out::println); // hello, world

// BiPredicate<T, U>
BiPredicate<String, String> startsWith =
    (s, prefix) -> s.startsWith(prefix);
System.out.println(startsWith.test("hello", "he")); // true

// Combining predicates in a filtering pipeline
Predicate<Integer> between10and100 = ((Predicate<Integer>)(n -> n > 10))
    .and(n -> n < 100);
List.of(5, 15, 50, 150).stream()
    .filter(between10and100)
    .forEach(System.out::println); // 15, 50

Method References

Method references are syntactic sugar for lambdas that do nothing except call a single existing method. There are four kinds. A static method reference (Class::staticMethod) maps directly to a static call. An instance method reference on a specific object (obj::method) captures that particular instance and calls its method. An instance method reference on an arbitrary object of a type (Class::instanceMethod) receives the instance as the lambda's first argument: the compiler infers that the first parameter is the receiver, so String::toUpperCase satisfies Function<String, String>. A constructor reference (Class::new) calls a constructor and can satisfy a Supplier, Function, or BiFunction depending on arity. All four are verified by the compiler for type safety at the point of assignment.

Java
// Four kinds of method references:

// 1. Static method reference: ClassName::staticMethod
Function<String, Integer> parse = Integer::parseInt;    // Integer.parseInt(s)
Function<Double, Double>  abs   = Math::abs;

// 2. Instance method of a particular object: instance::method
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("Alice")); // Hello, Alice

// 3. Instance method of an arbitrary object of a type: ClassName::instanceMethod
// (receiver becomes the first argument)
Function<String, String>  toUpper  = String::toUpperCase;   // s.toUpperCase()
Function<String, Integer> getLen   = String::length;         // s.length()
BiFunction<String, String, Boolean> startsWith = String::startsWith;

// 4. Constructor reference: ClassName::new
Supplier<ArrayList<String>> listMaker = ArrayList::new;
Function<Integer, ArrayList<String>> sizedList = ArrayList::new;
BiFunction<String, Integer, StringBuilder> sbFactory =
    (s, cap) -> new StringBuilder(s); // no matching constructor, use lambda

// Method references with streams
List<String> names = List.of("alice", "bob", "charlie");
names.stream()
    .map(String::toUpperCase)          // instance method ref
    .sorted(String::compareTo)          // instance method ref as Comparator
    .forEach(System.out::println);      // static-like (PrintStream instance ref)

// Comparator method references
List<Person> people = getPeople();
people.sort(Comparator.comparing(Person::name)            // getter ref
                       .thenComparingInt(Person::age));    // int-returning getter

Custom Functional Interfaces

Any interface with exactly one abstract method is a functional interface and can be used with lambdas, but annotating it with @FunctionalInterface adds a compile-time check: the compiler will reject the interface if it has zero or more than one abstract method, preventing accidental breakage when the interface evolves. The two main reasons to define a custom functional interface rather than reusing a standard one are checked exceptions and semantic naming. The standard types do not declare checked exceptions, so lambdas that throw them require a try/catch inside; a custom ThrowingFunction<T, R> propagates the checked exception cleanly. Semantic naming is equally valuable: PasswordValidator communicates intent more precisely than Predicate<String> even though they have the same shape, making code at the call site self-documenting.

Java
// @FunctionalInterface — annotate custom functional interfaces
// The annotation enforces exactly one abstract method at compile time
@FunctionalInterface
interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;

    // Can have default methods — they don't count as the abstract method
    default <V> ThrowingFunction<T, V> andThen(ThrowingFunction<R, V> after) {
        return t -> after.apply(this.apply(t));
    }

    // Static factory to wrap and re-throw checked exceptions
    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: stream operations that throw checked exceptions
List<String> paths = List.of("/etc/hosts", "/etc/resolv.conf");
paths.stream()
    .map(ThrowingFunction.wrap(path -> Files.readString(Path.of(path))))
    .forEach(System.out::println);

// Tri-function example
@FunctionalInterface
interface TriFunction<A, B, C, R> {
    R apply(A a, B b, C c);
}

TriFunction<Integer, Integer, Integer, Integer> clamp =
    (value, min, max) -> Math.min(Math.max(value, min), max);
System.out.println(clamp.apply(15, 0, 10)); // 10

// Runnable and Callable — older functional interfaces that predate Java 8
// but are still used extensively
Runnable r = () -> System.out.println("run");
Callable<Integer> c = () -> 42; // like Supplier but throws checked exceptions
Custom functional interfaces are most useful when you need checked exception support or a more specific name that documents intent (e.g., Transformer<T> vs Function<T, T>). For everything else, prefer the standard java.util.function types to maximize interoperability with the JDK and third-party libraries.