Skip to main content

Functional Interfaces and Lambdas

A functional interface is an interface with exactly one abstract method. Lambdas and method references are shorthand for implementing these interfaces. Together, they are the foundation of Java's functional programming style -- streams, Optional, and CompletableFuture all depend on them.

Quick start

// Lambda implementing Predicate<String>
Predicate<String> isLong = s -> s.length() > 5;

// Method reference implementing Function<String, Integer>
Function<String, Integer> toLength = String::length;

// Using them
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");

List<Integer> longNameLengths = names.stream()
.filter(isLong)
.map(toLength)
.toList();
// [7, 5]... wait -- "Charlie" (7) and "Diana" (5)? No, Diana is 5 which is not > 5.
// Result: [7] (only "Charlie")

Built-in functional interfaces

Java provides a rich set in java.util.function:

Core four

InterfaceSignaturePurposeExample
Function<T, R>R apply(T t)Transform T to RString::length
Predicate<T>boolean test(T t)Test a conditions -> s.isEmpty()
Consumer<T>void accept(T t)Perform a side effectSystem.out::println
Supplier<T>T get()Produce a value() -> new ArrayList<>()

Two-argument variants

InterfaceSignatureExample
BiFunction<T, U, R>R apply(T t, U u)(a, b) -> a + b
BiPredicate<T, U>boolean test(T t, U u)(s, len) -> s.length() > len
BiConsumer<T, U>void accept(T t, U u)map::put

Specialised variants

InterfaceSignatureAvoids
UnaryOperator<T>T apply(T t)Function<T, T>
BinaryOperator<T>T apply(T t1, T t2)BiFunction<T, T, T>
IntFunction<R>R apply(int value)Boxing
ToIntFunction<T>int applyAsInt(T value)Boxing
IntPredicateboolean test(int value)Boxing
IntConsumervoid accept(int value)Boxing
IntSupplierint getAsInt()Boxing

Lambda syntax

// Full form
(String s) -> { return s.length(); }

// Inferred parameter type
(s) -> { return s.length(); }

// Single parameter -- parentheses optional
s -> { return s.length(); }

// Single expression -- braces and return optional
s -> s.length()

// No parameters
() -> System.out.println("hello")

// Multiple parameters
(a, b) -> a + b

// Multiple statements (need braces and return)
(a, b) -> {
int sum = a + b;
System.out.println("Sum: " + sum);
return sum;
}

Method references

Method references are a shorthand for lambdas that just call an existing method:

TypeSyntaxEquivalent lambda
Static methodInteger::parseInts -> Integer.parseInt(s)
Instance method (on parameter)String::toUpperCases -> s.toUpperCase()
Instance method (on object)System.out::printlns -> System.out.println(s)
ConstructorArrayList::new() -> new ArrayList<>()
List<String> words = List.of("hello", "world");

// Static method reference
List<Integer> parsed = List.of("1", "2", "3").stream()
.map(Integer::parseInt)
.toList();

// Instance method reference (on each element)
List<String> upper = words.stream()
.map(String::toUpperCase)
.toList();

// Bound instance method reference (on a specific object)
words.stream().forEach(System.out::println);

// Constructor reference
List<List<String>> lists = words.stream()
.map(w -> List.of(w.split("")))
.toList();

Composing functions

Functional interfaces provide default methods for composition:

Function composition

Function<String, String> trim = String::strip;
Function<String, String> lower = String::toLowerCase;
Function<String, Integer> length = String::length;

// andThen: apply trim, then lower, then length
Function<String, Integer> pipeline = trim.andThen(lower).andThen(length);
pipeline.apply(" Hello World "); // 11

// compose: apply lower first, then trim (reverse order)
Function<String, String> composed = trim.compose(lower);
composed.apply(" HELLO "); // "hello"

Predicate composition

Predicate<String> isNotEmpty = s -> !s.isEmpty();
Predicate<String> isShort = s -> s.length() < 5;
Predicate<String> startsWithA = s -> s.startsWith("A");

// Combine with and, or, negate
Predicate<String> shortNonEmptyStartingWithA =
isNotEmpty.and(isShort).and(startsWithA);

Predicate<String> longOrStartsWithA =
isShort.negate().or(startsWithA);

List<String> names = List.of("Alice", "Bob", "Al", "", "Charlie", "Amy");

List<String> result = names.stream()
.filter(shortNonEmptyStartingWithA)
.toList();
// [Al, Amy]

Consumer chaining

Consumer<String> log = s -> System.out.println("LOG: " + s);
Consumer<String> save = s -> database.save(s);

// andThen: log first, then save
Consumer<String> logAndSave = log.andThen(save);
logAndSave.accept("event happened");

Writing custom functional interfaces

Use @FunctionalInterface to enforce exactly one abstract method:

@FunctionalInterface
interface Validator<T> {
boolean isValid(T value);

// Default methods are allowed
default Validator<T> and(Validator<T> other) {
return value -> this.isValid(value) && other.isValid(value);
}

default Validator<T> or(Validator<T> other) {
return value -> this.isValid(value) || other.isValid(value);
}

default Validator<T> negate() {
return value -> !this.isValid(value);
}
}

// Usage
Validator<String> notBlank = s -> s != null && !s.isBlank();
Validator<String> maxLength = s -> s.length() <= 100;
Validator<String> noHtml = s -> !s.contains("<");

Validator<String> safeInput = notBlank.and(maxLength).and(noHtml);

safeInput.isValid("Hello"); // true
safeInput.isValid(""); // false
safeInput.isValid("<script>alert"); // false

Functional interface with generics

@FunctionalInterface
interface Transformer<T, R> {
R transform(T input);

default <V> Transformer<T, V> andThen(Transformer<R, V> after) {
return input -> after.transform(this.transform(input));
}
}

Transformer<String, Integer> toLength = String::length;
Transformer<Integer, Boolean> isEven = n -> n % 2 == 0;

Transformer<String, Boolean> hasEvenLength = toLength.andThen(isEven);
hasEvenLength.transform("hello"); // false (5 is odd)
hasEvenLength.transform("Java"); // true (4 is even)

Practical examples

Configurable retry logic

public static <T> T retry(Supplier<T> action, int maxAttempts, Predicate<Exception> retryable) {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return action.get();
} catch (Exception e) {
if (attempt == maxAttempts || !retryable.test(e)) {
throw e;
}
}
}
throw new IllegalStateException("unreachable");
}

// Usage
String result = retry(
() -> httpClient.send(request), // Supplier<String>
3, // max attempts
e -> e instanceof IOException // Predicate<Exception>
);

Strategy pattern with lambdas

record Order(double total) {}

// Instead of a Strategy interface with implementations:
Function<Order, Double> flatDiscount = order -> order.total() - 10;
Function<Order, Double> percentDiscount = order -> order.total() * 0.9;
Function<Order, Double> noDiscount = Order::total;

// Select strategy at runtime
Function<Order, Double> strategy = isPremium ? percentDiscount : flatDiscount;
double finalPrice = strategy.apply(new Order(100.0));

Common pitfalls

PitfallProblemFix
Checked exceptions in lambdasFunction<T,R> does not allow checked exceptionsWrap in try/catch inside the lambda, or create a custom functional interface that throws
Capturing mutable variablesfor (int i...) -- lambda captures effectively final variables onlyUse a final copy or IntStream
this in lambdasthis refers to the enclosing class, not the lambdaExpected behavior, but can be surprising
Overusing lambdasComplex multi-line lambdas are hard to readExtract to a named method and use a method reference
Missing @FunctionalInterfaceNon-annotated interfaces can accidentally gain a second abstract methodAlways annotate custom functional interfaces

See also