Functional Interfaces

1. What is a Functional Interface?

Imagine a contract that says: “You must do exactly one thing.” It does not care what that thing is — it could be filtering data, transforming text, printing output, or computing a value. But the contract only has one obligation. That is a functional interface in Java.

A functional interface is a Java interface that contains exactly one abstract method. This is often called a SAM (Single Abstract Method) interface. Because there is only one method to implement, the compiler can unambiguously match a lambda expression or method reference to that method.

Key concept: Functional interfaces are the bridge between object-oriented Java and functional programming. Every lambda expression in Java targets a functional interface. Without functional interfaces, lambdas would not exist in Java.

Important rules:

  • Exactly one abstract method — this is the defining rule
  • Default methods do not count — an interface can have any number of default methods and still be functional
  • Static methods do not count — static methods in interfaces are not abstract
  • Methods from Object do not count — toString(), equals(), and hashCode() are inherited from Object and do not count toward the abstract method limit
  • The @FunctionalInterface annotation is optional but recommended — it tells the compiler to enforce the single-abstract-method rule
// A valid functional interface -- exactly one abstract method
@FunctionalInterface
public interface Greeting {
    String greet(String name);  // the single abstract method
}

// Still functional -- default methods don't count
@FunctionalInterface
public interface Transformer {
    String transform(String input);  // the single abstract method

    default String transformAndTrim(String input) {
        return transform(input).trim();
    }

    default String transformAndUpperCase(String input) {
        return transform(input).toUpperCase();
    }
}

// Still functional -- static methods and Object methods don't count
@FunctionalInterface
public interface Validator {
    boolean validate(String input);  // the single abstract method

    static Validator alwaysTrue() {
        return input -> true;
    }

    // toString() comes from Object -- does NOT count
    String toString();
}

// NOT functional -- two abstract methods (compiler error with @FunctionalInterface)
// @FunctionalInterface  // would cause compile error
interface NotFunctional {
    void methodA();
    void methodB();
}

1.1 The @FunctionalInterface Annotation

The @FunctionalInterface annotation serves two purposes:

  • Documentation — It signals to other developers that this interface is designed to be used with lambdas.
  • Compiler enforcement — If someone accidentally adds a second abstract method, the compiler will report an error.

You do not need @FunctionalInterface for an interface to work with lambdas. Any interface with exactly one abstract method will work. But adding the annotation is a best practice because it prevents accidental breakage.

1.2 Using a Functional Interface with Lambdas

@FunctionalInterface
interface MathOperation {
    double calculate(double a, double b);
}

public class FunctionalInterfaceBasic {
    public static void main(String[] args) {

        // Lambda expressions implementing the same functional interface
        MathOperation addition       = (a, b) -> a + b;
        MathOperation subtraction    = (a, b) -> a - b;
        MathOperation multiplication = (a, b) -> a * b;
        MathOperation division       = (a, b) -> b != 0 ? a / b : 0;

        System.out.println("10 + 5  = " + addition.calculate(10, 5));
        System.out.println("10 - 5  = " + subtraction.calculate(10, 5));
        System.out.println("10 * 5  = " + multiplication.calculate(10, 5));
        System.out.println("10 / 5  = " + division.calculate(10, 5));
        // Output:
        // 10 + 5  = 15.0
        // 10 - 5  = 5.0
        // 10 * 5  = 50.0
        // 10 / 5  = 2.0

        // Passing a lambda to a method that accepts the functional interface
        printResult("Addition", addition, 20, 3);
        printResult("Division", division, 20, 3);
        // Output:
        // Addition: 23.0
        // Division: 6.666666666666667
    }

    static void printResult(String label, MathOperation op, double a, double b) {
        System.out.println(label + ": " + op.calculate(a, b));
    }
}

2. Pre-Java 8 Functional Interfaces

Functional interfaces are not new to Java 8 — the concept existed long before, just without the name or the annotation. Many classic Java interfaces happen to have exactly one abstract method, making them functional interfaces by definition. Java 8 simply gave them a formal name and made them compatible with lambda expressions.

Interface Package Abstract Method Used For
Runnable java.lang void run() Running code in threads
Callable<V> java.util.concurrent V call() Tasks that return a value
Comparator<T> java.util int compare(T o1, T o2) Sorting and ordering
ActionListener java.awt.event void actionPerformed(ActionEvent) GUI event handling
FileFilter java.io boolean accept(File) Filtering files
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class PreJava8FunctionalInterfaces {
    public static void main(String[] args) {

        // --- Runnable: Before and After ---

        // Before Java 8: Anonymous inner class
        Runnable oldRunnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("Running with anonymous class");
            }
        };

        // Java 8+: Lambda expression
        Runnable newRunnable = () -> System.out.println("Running with lambda");

        new Thread(oldRunnable).start();
        new Thread(newRunnable).start();
        new Thread(() -> System.out.println("Running inline")).start();


        // --- Comparator: Before and After ---

        List names = Arrays.asList("Charlie", "Alice", "Bob");

        // Before Java 8
        Collections.sort(names, new Comparator() {
            @Override
            public int compare(String a, String b) {
                return a.compareTo(b);
            }
        });
        System.out.println("Old sort: " + names);

        // Java 8+
        List names2 = Arrays.asList("Charlie", "Alice", "Bob");
        names2.sort((a, b) -> a.compareTo(b));
        System.out.println("Lambda sort: " + names2);

        // Even shorter with method reference
        List names3 = Arrays.asList("Charlie", "Alice", "Bob");
        names3.sort(String::compareTo);
        System.out.println("Method ref sort: " + names3);
    }
}

3. The java.util.function Package

Java 8 introduced the java.util.function package, which contains 43 built-in functional interfaces. These cover the most common functional programming patterns so you rarely need to create your own. The four core interfaces are Predicate, Function, Consumer, and Supplier. Everything else is a variant of these four.

3.1 Predicate<T> — Test a Condition

A Predicate takes one argument and returns a boolean. It represents a condition or test. Its abstract method is boolean test(T t).

Default methods: and(), or(), negate()

Static method: isEqual()

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class PredicateExample {
    public static void main(String[] args) {

        // --- Basic Predicate ---
        Predicate isPositive = n -> n > 0;
        Predicate isEven = n -> n % 2 == 0;

        System.out.println("5 is positive: " + isPositive.test(5));    // true
        System.out.println("-3 is positive: " + isPositive.test(-3));  // false
        System.out.println("4 is even: " + isEven.test(4));            // true


        // --- Predicate.and() -- both conditions must be true ---
        Predicate isPositiveAndEven = isPositive.and(isEven);
        System.out.println("4 is positive AND even: " + isPositiveAndEven.test(4));   // true
        System.out.println("-2 is positive AND even: " + isPositiveAndEven.test(-2)); // false


        // --- Predicate.or() -- at least one condition must be true ---
        Predicate isPositiveOrEven = isPositive.or(isEven);
        System.out.println("-2 is positive OR even: " + isPositiveOrEven.test(-2));   // true
        System.out.println("-3 is positive OR even: " + isPositiveOrEven.test(-3));   // false


        // --- Predicate.negate() -- reverse the condition ---
        Predicate isNotPositive = isPositive.negate();
        System.out.println("-5 is NOT positive: " + isNotPositive.test(-5));  // true


        // --- Predicate.isEqual() -- static factory for equality check ---
        Predicate isHello = Predicate.isEqual("Hello");
        System.out.println("isHello(Hello): " + isHello.test("Hello"));  // true
        System.out.println("isHello(World): " + isHello.test("World"));  // false


        // --- Using Predicate with Streams ---
        List numbers = Arrays.asList(-5, -2, 0, 3, 4, 7, 8, -1, 6);

        List positiveEvens = numbers.stream()
                .filter(isPositive.and(isEven))
                .collect(Collectors.toList());
        System.out.println("Positive evens: " + positiveEvens);
        // Output: Positive evens: [4, 8, 6]
    }
}

3.2 Function<T, R> — Transform Input to Output

A Function takes one argument of type T and returns a result of type R. Its abstract method is R apply(T t). It represents a transformation or mapping.

Default methods: andThen(), compose()

Static method: identity()

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

public class FunctionExample {
    public static void main(String[] args) {

        // --- Basic Function ---
        Function stringLength = String::length;
        Function toUpper = String::toUpperCase;
        Function intToString = n -> "Number: " + n;

        System.out.println("Length of 'Hello': " + stringLength.apply("Hello"));  // 5
        System.out.println("Upper of 'hello': " + toUpper.apply("hello"));        // HELLO
        System.out.println(intToString.apply(42));                                 // Number: 42


        // --- andThen() -- apply this function, THEN apply the next ---
        // toUpper first, then stringLength
        Function upperThenLength = toUpper.andThen(stringLength);
        System.out.println("'hello' -> upper -> length: " + upperThenLength.apply("hello"));
        // Output: 5


        // --- compose() -- apply the OTHER function first, THEN this one ---
        // stringLength is applied AFTER toUpper
        Function composedLength = stringLength.compose(toUpper);
        System.out.println("Composed: " + composedLength.apply("hello"));  // 5

        // Another compose example: trim first, then upper
        Function trim = String::trim;
        Function trimThenUpper = toUpper.compose(trim);
        System.out.println("'  hello  ' -> trim -> upper: " + trimThenUpper.apply("  hello  "));
        // Output: HELLO


        // --- Function.identity() -- returns the input unchanged ---
        Function noChange = Function.identity();
        System.out.println("Identity: " + noChange.apply("same"));  // same


        // --- Using Function with Streams ---
        List names = Arrays.asList("alice", "bob", "charlie");

        List formatted = names.stream()
                .map(toUpper.andThen(s -> ">> " + s))
                .collect(Collectors.toList());
        System.out.println("Formatted: " + formatted);
        // Output: Formatted: [>> ALICE, >> BOB, >> CHARLIE]
    }
}

andThen() vs compose() — Visual Explanation

Method Execution Order Read As
f.andThen(g) Apply f first, then g “Do f, and then do g”
f.compose(g) Apply g first, then f “f is composed of g applied first”

Think of andThen as a pipeline (left to right) and compose as mathematical composition (right to left, like f(g(x))).

3.3 Consumer<T> — Perform an Action

A Consumer takes one argument and returns nothing (void). Its abstract method is void accept(T t). It represents a side-effect operation: printing, logging, writing to a database, etc.

Default method: andThen()

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerExample {
    public static void main(String[] args) {

        // --- Basic Consumer ---
        Consumer printer = System.out::println;
        Consumer yeller = s -> System.out.println(s.toUpperCase() + "!!!");

        printer.accept("Hello");          // Output: Hello
        yeller.accept("watch out");       // Output: WATCH OUT!!!


        // --- andThen() -- chain consumers ---
        Consumer printThenYell = printer.andThen(yeller);
        printThenYell.accept("hello");
        // Output:
        // hello
        // HELLO!!!


        // --- Practical: log before processing ---
        Consumer logger = s -> System.out.println("[LOG] Processing: " + s);
        Consumer processor = s -> System.out.println("[RESULT] " + s.toUpperCase());

        Consumer logAndProcess = logger.andThen(processor);

        List items = Arrays.asList("apple", "banana", "cherry");
        items.forEach(logAndProcess);
        // Output:
        // [LOG] Processing: apple
        // [RESULT] APPLE
        // [LOG] Processing: banana
        // [RESULT] BANANA
        // [LOG] Processing: cherry
        // [RESULT] CHERRY


        // --- Using Consumer with forEach ---
        List numbers = Arrays.asList(1, 2, 3, 4, 5);

        Consumer printSquare = n -> System.out.println(n + " squared = " + (n * n));
        numbers.forEach(printSquare);
        // Output:
        // 1 squared = 1
        // 2 squared = 4
        // 3 squared = 9
        // 4 squared = 16
        // 5 squared = 25
    }
}

3.4 Supplier<T> — Provide a Value

A Supplier takes no arguments and returns a value. Its abstract method is T get(). It represents a factory or lazy value provider. Suppliers are commonly used for deferred computation — the value is not computed until get() is called.

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class SupplierExample {
    public static void main(String[] args) {

        // --- Basic Supplier ---
        Supplier helloSupplier = () -> "Hello, World!";
        Supplier randomSupplier = Math::random;
        Supplier> listFactory = ArrayList::new;

        System.out.println(helloSupplier.get());     // Hello, World!
        System.out.println(randomSupplier.get());    // 0.7234... (random)


        // --- Lazy evaluation ---
        // The value is not computed until get() is called
        Supplier timestamp = System::currentTimeMillis;

        System.out.println("Before: " + timestamp.get());
        // simulate work
        for (int i = 0; i < 1000000; i++) { /* busy work */ }
        System.out.println("After: " + timestamp.get());
        // The two timestamps will be different


        // --- Factory pattern with Supplier ---
        Supplier sbFactory = StringBuilder::new;
        StringBuilder sb1 = sbFactory.get();  // new instance each time
        StringBuilder sb2 = sbFactory.get();  // another new instance
        sb1.append("First");
        sb2.append("Second");
        System.out.println(sb1);  // First
        System.out.println(sb2);  // Second


        // --- Generate infinite stream with Supplier ---
        Random random = new Random(42);
        Supplier diceRoll = () -> random.nextInt(6) + 1;

        List rolls = Stream.generate(diceRoll)
                .limit(10)
                .collect(Collectors.toList());
        System.out.println("Dice rolls: " + rolls);
        // Output: Dice rolls: [1, 5, 3, 2, 4, 2, 6, 3, 1, 4] (varies with seed)


        // --- Supplier with Optional.orElseGet ---
        String name = null;
        Supplier defaultName = () -> "Unknown User";

        String result = java.util.Optional.ofNullable(name)
                .orElseGet(defaultName);
        System.out.println("Result: " + result);
        // Output: Result: Unknown User
    }
}

3.5 UnaryOperator<T> and BinaryOperator<T>

UnaryOperator<T> is a specialization of Function<T, T> where the input and output types are the same. BinaryOperator<T> is a specialization of BiFunction<T, T, T> where all three types are the same.

Interface Extends Abstract Method Use Case
UnaryOperator<T> Function<T, T> T apply(T t) Transform a value to the same type
BinaryOperator<T> BiFunction<T, T, T> T apply(T t1, T t2) Combine two values of the same type
import java.util.Arrays;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;

public class OperatorExample {
    public static void main(String[] args) {

        // --- UnaryOperator: same input and output type ---
        UnaryOperator toUpper = String::toUpperCase;
        UnaryOperator addBrackets = s -> "[" + s + "]";
        UnaryOperator doubleIt = n -> n * 2;

        System.out.println(toUpper.apply("hello"));      // HELLO
        System.out.println(addBrackets.apply("java"));    // [java]
        System.out.println(doubleIt.apply(5));             // 10


        // --- Chaining UnaryOperators ---
        UnaryOperator upperAndBracket = s -> addBrackets.apply(toUpper.apply(s));
        System.out.println(upperAndBracket.apply("hello"));  // [HELLO]


        // --- Using UnaryOperator with List.replaceAll ---
        List names = Arrays.asList("alice", "bob", "charlie");
        names.replaceAll(String::toUpperCase);
        System.out.println("ReplaceAll: " + names);
        // Output: ReplaceAll: [ALICE, BOB, CHARLIE]


        // --- BinaryOperator: combine two values of the same type ---
        BinaryOperator add = Integer::sum;
        BinaryOperator max = Integer::max;
        BinaryOperator concat = String::concat;

        System.out.println("Sum: " + add.apply(10, 20));     // 30
        System.out.println("Max: " + max.apply(10, 20));     // 20
        System.out.println("Concat: " + concat.apply("Hello, ", "World!"));  // Hello, World!


        // --- BinaryOperator with Stream.reduce ---
        List numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                .reduce(0, Integer::sum);
        System.out.println("Stream sum: " + sum);
        // Output: Stream sum: 15

        int product = numbers.stream()
                .reduce(1, (a, b) -> a * b);
        System.out.println("Stream product: " + product);
        // Output: Stream product: 120


        // --- BinaryOperator.maxBy and minBy ---
        BinaryOperator longerString = BinaryOperator.maxBy(
                java.util.Comparator.comparingInt(String::length));

        System.out.println("Longer: " + longerString.apply("hello", "hi"));
        // Output: Longer: hello
    }
}

3.6 Bi-Variant Interfaces

The “Bi” variants accept two parameters instead of one. They mirror the single-parameter interfaces but with an extra input.

Interface Abstract Method Single-Param Equivalent
BiFunction<T, U, R> R apply(T t, U u) Function<T, R>
BiPredicate<T, U> boolean test(T t, U u) Predicate<T>
BiConsumer<T, U> void accept(T t, U u) Consumer<T>
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;

public class BiVariantExample {
    public static void main(String[] args) {

        // --- BiFunction: two inputs, one output ---
        BiFunction repeat = (s, n) -> s.repeat(n);
        System.out.println(repeat.apply("Ha", 3));
        // Output: HaHaHa

        BiFunction power = Math::pow;
        System.out.println("2^10 = " + power.apply(2.0, 10.0));
        // Output: 2^10 = 1024.0


        // --- BiPredicate: two inputs, boolean output ---
        BiPredicate longerThan = (s, len) -> s.length() > len;
        System.out.println("'hello' longer than 3: " + longerThan.test("hello", 3));  // true
        System.out.println("'hi' longer than 3: " + longerThan.test("hi", 3));        // false

        // Combining BiPredicates
        BiPredicate shorterThan10 = (s, len) -> s.length() < 10;
        BiPredicate validLength = longerThan.and(shorterThan10);
        System.out.println("'hello' valid (>3 and <10): " + validLength.test("hello", 3));  // true


        // --- BiConsumer: two inputs, no output ---
        BiConsumer printEntry = (key, value) ->
                System.out.println(key + " = " + value);

        printEntry.accept("Score", 95);
        // Output: Score = 95

        // BiConsumer with Map.forEach
        Map scores = new HashMap<>();
        scores.put("Alice", 95);
        scores.put("Bob", 87);
        scores.put("Charlie", 92);

        System.out.println("--- Scores ---");
        scores.forEach((name, score) ->
                System.out.println(name + ": " + score + (score >= 90 ? " (A)" : " (B)")));
        // Output:
        // --- Scores ---
        // Alice: 95 (A)
        // Bob: 87 (B)
        // Charlie: 92 (A)
    }
}

3.7 Primitive Specializations

Generics in Java cannot use primitive types (int, long, double). When you use Function<Integer, Integer>, every int value gets autoboxed into an Integer object, which hurts performance. To solve this, java.util.function provides primitive specializations that work directly with int, long, and double — avoiding autoboxing entirely.

Category Primitive Specializations
Predicate IntPredicate, LongPredicate, DoublePredicate
Function IntFunction<R>, LongFunction<R>, DoubleFunction<R>
To-type Function ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T>
Type-to-type Function IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction, DoubleToIntFunction, DoubleToLongFunction
Consumer IntConsumer, LongConsumer, DoubleConsumer
Supplier IntSupplier, LongSupplier, DoubleSupplier, BooleanSupplier
UnaryOperator IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator
BinaryOperator IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
Bi-variant ObjIntConsumer<T>, ObjLongConsumer<T>, ObjDoubleConsumer<T>
import java.util.function.*;
import java.util.stream.IntStream;

public class PrimitiveSpecializationExample {
    public static void main(String[] args) {

        // --- IntPredicate: avoids autoboxing int to Integer ---
        IntPredicate isEven = n -> n % 2 == 0;
        IntPredicate isPositive = n -> n > 0;

        System.out.println("4 is even: " + isEven.test(4));         // true
        System.out.println("4 is positive AND even: " +
                isPositive.and(isEven).test(4));                     // true


        // --- IntFunction: int input, generic output ---
        IntFunction intToLabel = n -> "Item #" + n;
        System.out.println(intToLabel.apply(5));
        // Output: Item #5


        // --- ToIntFunction: generic input, int output ---
        ToIntFunction stringLen = String::length;
        System.out.println("Length of 'hello': " + stringLen.applyAsInt("hello"));
        // Output: Length of 'hello': 5


        // --- IntUnaryOperator: int to int ---
        IntUnaryOperator doubleIt = n -> n * 2;
        IntUnaryOperator addTen = n -> n + 10;

        // Chain: double first, then add 10
        IntUnaryOperator doubleThenAdd = doubleIt.andThen(addTen);
        System.out.println("5 -> double -> add10: " + doubleThenAdd.applyAsInt(5));
        // Output: 5 -> double -> add10: 20


        // --- IntBinaryOperator: two ints, one int result ---
        IntBinaryOperator max = Integer::max;
        System.out.println("Max of 3 and 7: " + max.applyAsInt(3, 7));
        // Output: Max of 3 and 7: 7


        // --- Performance benefit with IntStream ---
        int sum = IntStream.rangeClosed(1, 100)
                .filter(isEven)       // IntPredicate -- no autoboxing
                .sum();
        System.out.println("Sum of even numbers 1-100: " + sum);
        // Output: Sum of even numbers 1-100: 2550
    }
}

4. Composing Functional Interfaces

One of the most powerful features of functional interfaces is composition — combining simple functions into complex behavior. This is the essence of functional programming: building big things from small, reusable pieces.

4.1 Chaining Predicates

Predicates can be combined using and(), or(), and negate() to build complex conditions from simple ones.

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class PredicateComposition {
    public static void main(String[] args) {

        // Individual predicates
        Predicate isNotNull = s -> s != null;
        Predicate isNotEmpty = s -> !s.isEmpty();
        Predicate isNotBlank = s -> !s.isBlank();
        Predicate startsWithJ = s -> s.startsWith("J");
        Predicate longerThan3 = s -> s.length() > 3;

        // Compose a complex validation predicate
        Predicate isValidJavaName = isNotNull
                .and(isNotEmpty)
                .and(isNotBlank)
                .and(startsWithJ)
                .and(longerThan3);

        List candidates = Arrays.asList(
            "Java", "JavaScript", null, "", "  ", "J", "Jolt",
            "Python", "Jakarta", "JUnit"
        );

        List valid = candidates.stream()
                .filter(isValidJavaName)
                .collect(Collectors.toList());

        System.out.println("Valid Java names: " + valid);
        // Output: Valid Java names: [Java, JavaScript, Jolt, Jakarta, JUnit]


        // Reuse individual predicates in different combinations
        Predicate isValidNonJava = isNotNull
                .and(isNotEmpty)
                .and(startsWithJ.negate())  // does NOT start with J
                .and(longerThan3);

        List nonJava = candidates.stream()
                .filter(isValidNonJava)
                .collect(Collectors.toList());

        System.out.println("Valid non-Java names: " + nonJava);
        // Output: Valid non-Java names: [Python]
    }
}

4.2 Composing Functions

Functions can be chained using andThen() (left to right) or compose() (right to left) to create transformation pipelines.

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

public class FunctionComposition {
    public static void main(String[] args) {

        // Individual transformation steps
        Function trim = String::trim;
        Function toUpper = String::toUpperCase;
        Function addPrefix = s -> ">> " + s;
        Function addSuffix = s -> s + " <<";

        // Build a pipeline: trim -> uppercase -> add prefix -> add suffix
        Function pipeline = trim
                .andThen(toUpper)
                .andThen(addPrefix)
                .andThen(addSuffix);

        System.out.println(pipeline.apply("  hello world  "));
        // Output: >> HELLO WORLD <<


        // Apply pipeline to a list
        List rawInputs = Arrays.asList("  alice  ", " bob ", "  charlie  ");

        List processed = rawInputs.stream()
                .map(pipeline)
                .collect(Collectors.toList());

        System.out.println(processed);
        // Output: [>> ALICE <<, >> BOB <<, >> CHARLIE <<]


        // Compose: build a transformation and then convert to length
        Function processedLength = pipeline.andThen(String::length);

        rawInputs.stream()
                .map(processedLength)
                .forEach(len -> System.out.println("Length: " + len));
        // Output:
        // Length: 14
        // Length: 12
        // Length: 16
    }
}

4.3 Consumer Chains

Consumers can be chained using andThen() to perform multiple side-effect actions in sequence. This is useful for logging, auditing, and multi-step processing.

import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
import java.util.function.Consumer;

public class ConsumerComposition {
    public static void main(String[] args) {

        // Individual consumers
        Consumer log = s -> System.out.println("[LOG] " + s);
        Consumer validate = s -> {
            if (s.length() < 2) {
                System.out.println("[WARN] '" + s + "' is too short");
            }
        };
        Consumer process = s -> System.out.println("[PROCESS] " + s.toUpperCase());

        // Chain: log -> validate -> process
        Consumer fullPipeline = log.andThen(validate).andThen(process);

        List inputs = Arrays.asList("hello", "a", "java", "b", "streams");

        System.out.println("=== Processing Pipeline ===");
        inputs.forEach(fullPipeline);
        // Output:
        // === Processing Pipeline ===
        // [LOG] hello
        // [PROCESS] HELLO
        // [LOG] a
        // [WARN] 'a' is too short
        // [PROCESS] A
        // [LOG] java
        // [PROCESS] JAVA
        // [LOG] b
        // [WARN] 'b' is too short
        // [PROCESS] B
        // [LOG] streams
        // [PROCESS] STREAMS


        // Practical: collecting and printing in one pass
        List collected = new ArrayList<>();

        Consumer collectAndPrint = ((Consumer) collected::add)
                .andThen(s -> System.out.println("Added: " + s));

        Arrays.asList("X", "Y", "Z").forEach(collectAndPrint);
        System.out.println("Collected: " + collected);
        // Output:
        // Added: X
        // Added: Y
        // Added: Z
        // Collected: [X, Y, Z]
    }
}

5. Creating Custom Functional Interfaces

Java’s built-in functional interfaces cover most common patterns: one input (Function), two inputs (BiFunction), no output (Consumer), no input (Supplier), and boolean output (Predicate). But sometimes you need something that does not fit, such as a function with three parameters or a function that throws a checked exception.

5.1 When to Create a Custom Functional Interface

  • You need more than two parameters (no TriFunction in the JDK)
  • You need a function that throws a checked exception
  • You want a domain-specific name that reads better than generic types
  • You need specific default methods for composition

5.2 TriFunction Example

import java.util.function.Function;

// Custom functional interface for three parameters
@FunctionalInterface
interface TriFunction {
    R apply(A a, B b, C c);

    // Default method for chaining
    default  TriFunction andThen(Function after) {
        return (a, b, c) -> after.apply(apply(a, b, c));
    }
}

public class CustomTriFunction {
    public static void main(String[] args) {

        // Three-parameter function: calculate total price
        TriFunction orderSummary =
                (product, quantity, price) ->
                    String.format("%s x%d = $%.2f", product, quantity, quantity * price);

        System.out.println(orderSummary.apply("Widget", 5, 9.99));
        // Output: Widget x5 = $49.95

        System.out.println(orderSummary.apply("Gadget", 3, 24.50));
        // Output: Gadget x3 = $73.50


        // Using andThen to chain
        TriFunction summaryLength =
                orderSummary.andThen(String::length);

        System.out.println("Summary length: " + summaryLength.apply("Widget", 5, 9.99));
        // Output: Summary length: 19


        // Three-parameter function: full name formatter
        TriFunction fullName =
                (first, middle, last) -> first + " " + middle + " " + last;

        System.out.println(fullName.apply("John", "Michael", "Smith"));
        // Output: John Michael Smith
    }
}

5.3 Checked Exception Functional Interface

Java’s built-in functional interfaces do not declare checked exceptions. This means you cannot use a lambda that throws IOException or SQLException with Function<T, R>. The solution is a custom functional interface with a throws clause.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

// Functional interface that allows checked exceptions
@FunctionalInterface
interface CheckedFunction {
    R apply(T t) throws Exception;
}

public class CheckedExceptionInterface {

    // Utility method to wrap CheckedFunction into a standard Function
    static  java.util.function.Function unchecked(CheckedFunction f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        };
    }

    // A method that throws a checked exception
    static Integer parseStrictly(String s) throws Exception {
        if (s == null || s.isBlank()) {
            throw new Exception("Input cannot be null or blank");
        }
        return Integer.parseInt(s.trim());
    }

    public static void main(String[] args) {

        List inputs = Arrays.asList("1", "2", "3", "4", "5");

        // Without the wrapper, this would not compile because
        // parseStrictly throws a checked Exception.
        // With unchecked(), we wrap it into a standard Function.
        List numbers = inputs.stream()
                .map(unchecked(CheckedExceptionInterface::parseStrictly))
                .collect(Collectors.toList());

        System.out.println("Parsed: " + numbers);
        // Output: Parsed: [1, 2, 3, 4, 5]
    }
}

5.4 Domain-Specific Functional Interface

Sometimes a custom interface with a meaningful name is clearer than a generic Function<User, Boolean>. Domain-specific names make the code self-documenting.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class DomainSpecificInterface {

    // Domain-specific functional interfaces
    @FunctionalInterface
    interface Converter {
        T convert(F from);
    }

    @FunctionalInterface
    interface Validator {
        boolean isValid(T item);

        default Validator and(Validator other) {
            return item -> isValid(item) && other.isValid(item);
        }

        default Validator or(Validator other) {
            return item -> isValid(item) || other.isValid(item);
        }

        default Validator negate() {
            return item -> !isValid(item);
        }
    }

    static class User {
        String name;
        int age;
        String email;

        User(String name, int age, String email) {
            this.name = name;
            this.age = age;
            this.email = email;
        }

        public String toString() {
            return name + " (age " + age + ", " + email + ")";
        }
    }

    public static void main(String[] args) {

        // Converter usage -- clear intent
        Converter stringToInt = Integer::parseInt;
        Converter csvToUser = csv -> {
            String[] parts = csv.split(",");
            return new User(parts[0].trim(), Integer.parseInt(parts[1].trim()), parts[2].trim());
        };

        System.out.println(stringToInt.convert("42"));
        // Output: 42

        User user = csvToUser.convert("Alice, 30, alice@example.com");
        System.out.println(user);
        // Output: Alice (age 30, alice@example.com)


        // Validator usage -- composable and domain-meaningful
        Validator hasName = u -> u.name != null && !u.name.isBlank();
        Validator isAdult = u -> u.age >= 18;
        Validator hasEmail = u -> u.email != null && u.email.contains("@");

        Validator isValidUser = hasName.and(isAdult).and(hasEmail);

        List users = Arrays.asList(
            new User("Alice", 30, "alice@example.com"),
            new User("", 25, "bob@example.com"),
            new User("Charlie", 15, "charlie@example.com"),
            new User("Diana", 28, null)
        );

        List validUsers = users.stream()
                .filter(isValidUser::isValid)
                .collect(Collectors.toList());

        System.out.println("Valid users: " + validUsers);
        // Output: Valid users: [Alice (age 30, alice@example.com)]
    }
}

6. Functional Interface Reference Table

This table lists every functional interface in java.util.function, organized by category. Use it as a quick reference when deciding which interface to use.

6.1 Core Interfaces

Interface Abstract Method Input Output Use Case
Predicate<T> boolean test(T) T boolean Filtering, conditions
Function<T, R> R apply(T) T R Transformations, mapping
Consumer<T> void accept(T) T void Side effects (print, save)
Supplier<T> T get() none T Factories, lazy values
UnaryOperator<T> T apply(T) T T Same-type transformation
BinaryOperator<T> T apply(T, T) T, T T Reducing, combining

6.2 Bi-Variant Interfaces

Interface Abstract Method Input Output Use Case
BiPredicate<T, U> boolean test(T, U) T, U boolean Two-input conditions
BiFunction<T, U, R> R apply(T, U) T, U R Two-input transformations
BiConsumer<T, U> void accept(T, U) T, U void Map.forEach, two-arg actions

6.3 Primitive Predicate Specializations

Interface Abstract Method Avoids Boxing
IntPredicate boolean test(int) Predicate<Integer>
LongPredicate boolean test(long) Predicate<Long>
DoublePredicate boolean test(double) Predicate<Double>

6.4 Primitive Function Specializations

Interface Abstract Method Description
IntFunction<R> R apply(int) int input, generic output
LongFunction<R> R apply(long) long input, generic output
DoubleFunction<R> R apply(double) double input, generic output
ToIntFunction<T> int applyAsInt(T) generic input, int output
ToLongFunction<T> long applyAsLong(T) generic input, long output
ToDoubleFunction<T> double applyAsDouble(T) generic input, double output
IntToLongFunction long applyAsLong(int) int to long
IntToDoubleFunction double applyAsDouble(int) int to double
LongToIntFunction int applyAsInt(long) long to int
LongToDoubleFunction double applyAsDouble(long) long to double
DoubleToIntFunction int applyAsInt(double) double to int
DoubleToLongFunction long applyAsLong(double) double to long

6.5 Primitive Consumer and Supplier Specializations

Interface Abstract Method Description
IntConsumer void accept(int) Consumes int without boxing
LongConsumer void accept(long) Consumes long without boxing
DoubleConsumer void accept(double) Consumes double without boxing
ObjIntConsumer<T> void accept(T, int) Object + int consumer
ObjLongConsumer<T> void accept(T, long) Object + long consumer
ObjDoubleConsumer<T> void accept(T, double) Object + double consumer
IntSupplier int getAsInt() Supplies int without boxing
LongSupplier long getAsLong() Supplies long without boxing
DoubleSupplier double getAsDouble() Supplies double without boxing
BooleanSupplier boolean getAsBoolean() Supplies boolean without boxing

6.6 Primitive Operator Specializations

Interface Abstract Method Description
IntUnaryOperator int applyAsInt(int) int to int
LongUnaryOperator long applyAsLong(long) long to long
DoubleUnaryOperator double applyAsDouble(double) double to double
IntBinaryOperator int applyAsInt(int, int) Two ints to int
LongBinaryOperator long applyAsLong(long, long) Two longs to long
DoubleBinaryOperator double applyAsDouble(double, double) Two doubles to double

7. Complete Practical Example: Validation Framework

Let us build a reusable validation framework using Predicate composition. This demonstrates how functional interfaces enable elegant, composable designs that would require far more code with traditional OOP approaches.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class ValidationFramework {

    // --- ValidationResult: holds success/failure and messages ---
    static class ValidationResult {
        private final boolean valid;
        private final List errors;

        private ValidationResult(boolean valid, List errors) {
            this.valid = valid;
            this.errors = errors;
        }

        static ValidationResult success() {
            return new ValidationResult(true, List.of());
        }

        static ValidationResult failure(String error) {
            return new ValidationResult(false, List.of(error));
        }

        static ValidationResult combine(ValidationResult a, ValidationResult b) {
            if (a.valid && b.valid) return success();
            List allErrors = new ArrayList<>(a.errors);
            allErrors.addAll(b.errors);
            return new ValidationResult(false, allErrors);
        }

        boolean isValid()          { return valid; }
        List getErrors()   { return errors; }

        @Override
        public String toString() {
            return valid ? "VALID" : "INVALID: " + errors;
        }
    }


    // --- Validator: wraps a Predicate with an error message ---
    @FunctionalInterface
    interface Validator {
        ValidationResult validate(T t);

        default Validator and(Validator other) {
            return t -> ValidationResult.combine(this.validate(t), other.validate(t));
        }

        // Factory method: create a Validator from a Predicate and error message
        static  Validator of(Predicate predicate, String errorMessage) {
            return t -> predicate.test(t)
                    ? ValidationResult.success()
                    : ValidationResult.failure(errorMessage);
        }
    }


    // --- Domain class ---
    static class RegistrationForm {
        String username;
        String email;
        String password;
        int age;

        RegistrationForm(String username, String email, String password, int age) {
            this.username = username;
            this.email = email;
            this.password = password;
            this.age = age;
        }

        @Override
        public String toString() {
            return "Form{user='" + username + "', email='" + email + "', age=" + age + "}";
        }
    }


    public static void main(String[] args) {

        // --- Build individual validators using Predicate composition ---

        Validator usernameNotEmpty = Validator.of(
                f -> f.username != null && !f.username.isBlank(),
                "Username is required"
        );

        Validator usernameMinLength = Validator.of(
                f -> f.username != null && f.username.length() >= 3,
                "Username must be at least 3 characters"
        );

        Validator usernameMaxLength = Validator.of(
                f -> f.username == null || f.username.length() <= 20,
                "Username must be at most 20 characters"
        );

        Validator emailValid = Validator.of(
                f -> f.email != null && f.email.matches("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"),
                "Email is invalid"
        );

        Validator passwordStrong = Validator.of(
                f -> f.password != null && f.password.length() >= 8,
                "Password must be at least 8 characters"
        );

        Validator passwordHasDigit = Validator.of(
                f -> f.password != null && f.password.chars().anyMatch(Character::isDigit),
                "Password must contain at least one digit"
        );

        Validator passwordHasUpper = Validator.of(
                f -> f.password != null && f.password.chars().anyMatch(Character::isUpperCase),
                "Password must contain at least one uppercase letter"
        );

        Validator ageValid = Validator.of(
                f -> f.age >= 13 && f.age <= 120,
                "Age must be between 13 and 120"
        );


        // --- Compose all validators into one ---
        Validator fullValidator = usernameNotEmpty
                .and(usernameMinLength)
                .and(usernameMaxLength)
                .and(emailValid)
                .and(passwordStrong)
                .and(passwordHasDigit)
                .and(passwordHasUpper)
                .and(ageValid);


        // --- Test data ---
        List forms = Arrays.asList(
            new RegistrationForm("Alice", "alice@example.com", "SecurePass1", 25),
            new RegistrationForm("Bo", "bo@x.com", "weakpw", 10),
            new RegistrationForm("", "not-an-email", "12345678", 30),
            new RegistrationForm("ValidUser", "user@site.org", "MyP@ssw0rd", 45)
        );


        // --- Validate each form ---
        System.out.println("=== Registration Validation Results ===\n");

        for (RegistrationForm form : forms) {
            ValidationResult result = fullValidator.validate(form);
            System.out.println(form);
            System.out.println("  Result: " + result);
            System.out.println();
        }

        // Output:
        // === Registration Validation Results ===
        //
        // Form{user='Alice', email='alice@example.com', age=25}
        //   Result: VALID
        //
        // Form{user='Bo', email='bo@x.com', age=10}
        //   Result: INVALID: [Username must be at least 3 characters, Password must be at least 8 characters, Password must contain at least one uppercase letter, Age must be between 13 and 120]
        //
        // Form{user='', email='not-an-email', age=30}
        //   Result: INVALID: [Username is required, Username must be at least 3 characters, Email is invalid, Password must contain at least one uppercase letter]
        //
        // Form{user='ValidUser', email='user@site.org', age=45}
        //   Result: VALID


        // --- Filter only valid forms ---
        List validForms = forms.stream()
                .filter(f -> fullValidator.validate(f).isValid())
                .collect(Collectors.toList());

        System.out.println("Valid forms: " + validForms);
        // Output: Valid forms: [Form{user='Alice'...}, Form{user='ValidUser'...}]


        // --- Transform valid forms ---
        Function toWelcomeMessage =
                f -> "Welcome, " + f.username + "! Your account (" + f.email + ") has been created.";

        validForms.stream()
                .map(toWelcomeMessage)
                .forEach(System.out::println);
        // Output:
        // Welcome, Alice! Your account (alice@example.com) has been created.
        // Welcome, ValidUser! Your account (user@site.org) has been created.
    }
}

This validation framework demonstrates several functional interface concepts working together:

Concept Where Used How
Predicate Validator.of(predicate, message) Each validation rule is a Predicate
Composition (and) validator1.and(validator2) Combines validators into a single check
Custom Functional Interface Validator<T> Wraps Predicate with error messages
Function toWelcomeMessage Transforms valid forms into strings
Method Reference Character::isDigit, System.out::println Clean stream operations
Static Factory Validator.of() Creates validators declaratively

8. Summary

  • A functional interface has exactly one abstract method. It is the target type for lambda expressions and method references.
  • The @FunctionalInterface annotation is optional but recommended — it prevents accidental breakage by enforcing the single-method rule at compile time.
  • Java had functional interfaces before Java 8 (Runnable, Comparator, Callable). Java 8 formalized the concept and added the java.util.function package.
  • The four core interfaces are Predicate (test), Function (transform), Consumer (act), and Supplier (provide).
  • UnaryOperator and BinaryOperator are specializations of Function and BiFunction for same-type operations.
  • Bi-variants (BiFunction, BiPredicate, BiConsumer) handle two-parameter scenarios.
  • Primitive specializations (IntPredicate, LongFunction, etc.) avoid autoboxing for better performance.
  • Composition is the key power: chain predicates with and()/or()/negate(), functions with andThen()/compose(), and consumers with andThen().
  • Create custom functional interfaces when you need more than two parameters, checked exceptions, or domain-specific naming.



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *