Functional
Java admits substantial functional-style programming through several mechanisms — first-class functions through lambdas and method references, the streams API for higher-order operations, pattern matching for value-level dispatch, records and sealed types for immutable data and closed-set discrimination, and Optional<T> for explicit absence. The language is not purely functional and does not enforce immutability or referential transparency, but the patterns it supports cover most of the practical functional programming use cases. Modern idiomatic Java uses streams and lambdas pervasively; the language has shifted substantially from the explicitly-OOP style of Java 1.0 toward a multi-paradigm style.
This page surveys the functional surface and the conventions for using each mechanism. Lambdas and functional interfaces are covered in Methods and lambdas; streams in Streams; pattern matching in Pattern matching; records and sealed types in Classes, records, and sealed types.
First-class functions
Java does not have free functions but admits first-class function values through lambdas, method references, and functional interfaces:
Function<Integer, Integer> square = x -> x * x;
Function<Integer, Integer> double_ = x -> x * 2;
Function<Integer, Integer> chain = x -> double_.apply(square.apply(x)); // 2x²
int n = chain.apply(3); // 18
Functions can be stored, passed, returned, and combined:
public static <T, R> Function<List<T>, R> reduce(R identity, BiFunction<R, T, R> fn) {
return list -> {
R acc = identity;
for (T x : list) acc = fn.apply(acc, x);
return acc;
};
}
Function<List<Integer>, Integer> sum = reduce(0, Integer::sum);
Function<List<Integer>, Integer> product = reduce(1, (a, b) -> a * b);
The standard library’s functional interfaces (Function<T, R>, BiFunction<T, U, R>, Predicate<T>, Consumer<T>, Supplier<T>) are the conventional types for function-valued parameters and locals. The full enumeration is in Methods and lambdas.
Function composition
Function<T, R> admits composition through default methods:
Function<Integer, Integer> add5 = x -> x + 5;
Function<Integer, Integer> double_ = x -> x * 2;
Function<Integer, Integer> add5ThenDouble = add5.andThen(double_);
Function<Integer, Integer> doubleThenAdd5 = add5.compose(double_);
int a = add5ThenDouble.apply(3); // (3 + 5) * 2 = 16
int b = doubleThenAdd5.apply(3); // (3 * 2) + 5 = 11
The andThen method composes left-to-right (apply this, then the next); compose composes right-to-left. The convention varies by library; Java’s choice is consistent within itself.
Predicate<T> admits and, or, negate:
Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even = n -> n % 2 == 0;
Predicate<Integer> positiveAndEven = positive.and(even);
Predicate<Integer> positiveOrEven = positive.or(even);
Predicate<Integer> negative = positive.negate();
Consumer<T> admits andThen:
Consumer<String> log = s -> System.out.println("LOG: " + s);
Consumer<String> alert = s -> System.err.println("ALERT: " + s);
Consumer<String> both = log.andThen(alert);
both.accept("starting"); // logs then alerts
The composition mechanisms are sufficient for most functional-style programming; for more elaborate composition, third-party libraries (Vavr, FunctionalJava) provide richer surfaces.
Higher-order operations through streams
The streams API provides the conventional higher-order operations on collections:
import static java.util.stream.Collectors.*;
// Map
List<Integer> squares = numbers.stream()
.map(n -> n * n)
.toList();
// Filter
List<Integer> positives = numbers.stream()
.filter(n -> n > 0)
.toList();
// Reduce
int total = numbers.stream().reduce(0, Integer::sum);
// FlatMap
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.toList();
// Group
Map<String, List<Order>> byCustomer = orders.stream()
.collect(groupingBy(Order::customer));
The full treatment is in Streams. The principal point: streams are the conventional Java idiom for filter-map-reduce-style programming, replacing what older Java code wrote as explicit loops.
Optional<T> and the monadic operations
Optional<T> represents a possibly-absent value:
import java.util.Optional;
Optional<User> user = repository.findById(id);
if (user.isPresent()) {
process(user.get());
}
// Idiomatic:
user.ifPresent(this::process);
String name = user.map(User::name).orElse("unknown");
Optional<Address> address = user
.map(User::address)
.filter(Address::isValid);
address.ifPresentOrElse(
addr -> store(addr),
() -> log.warn("no valid address")
);
The principal monadic operations:
| Operation | Effect |
|---|---|
isPresent() / isEmpty() | Boolean test |
get() | Unwrap; throws NoSuchElementException if empty |
orElse(default) | Unwrap or default |
orElseGet(supplier) | Unwrap or lazily computed default |
orElseThrow() / orElseThrow(supplier) | Unwrap or throw |
map(fn) | Transform if present |
flatMap(fn) | Transform with flatten |
filter(pred) | Keep if predicate holds |
ifPresent(consumer) | Side-effect if present |
ifPresentOrElse(consumer, runnable) | Side-effect either way (since Java 9) |
or(supplier) | Alternative Optional if empty (since Java 9) |
stream() | Convert to a Stream<T> (since Java 9) |
The conventional uses:
- Return type for methods that may not produce a value (search, parse, lookup).
- Not as a parameter type (overuse leads to clutter).
- Not as a field type (Java’s nullability semantics for fields are clearer than wrapping in
Optional).
The Optional<T> chain is the conventional alternative to nested null-checks:
// Older:
String city = null;
if (user != null) {
Address addr = user.address();
if (addr != null) {
city = addr.city();
}
}
// With Optional:
String city = Optional.ofNullable(user)
.map(User::address)
.map(Address::city)
.orElse("unknown");
Pattern matching as functional discrimination
Pattern matching (Java 16+) admits value-level dispatch:
String describe(Object o) {
return switch (o) {
case null -> "null";
case Integer n -> "integer " + n;
case String s -> "string of length " + s.length();
case List<?> l -> "list of size " + l.size();
default -> "other";
};
}
Combined with sealed types and records, the mechanism approximates algebraic-data-type pattern matching:
public sealed interface JsonValue
permits JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject { }
double sum(JsonValue v) {
return switch (v) {
case JsonNumber(double n) -> n;
case JsonArray(List<JsonValue> items) ->
items.stream().mapToDouble(this::sum).sum();
case JsonObject(Map<String, JsonValue> entries) ->
entries.values().stream().mapToDouble(this::sum).sum();
case JsonNull n -> 0;
case JsonBool(boolean b) -> b ? 1 : 0;
case JsonString(String s) -> {
try { yield Double.parseDouble(s); }
catch (NumberFormatException ex) { yield 0; }
}
};
}
The full treatment is in Pattern matching.
Records and immutability
Records (Java 14) are immutable reference types with structural equality:
public record Point(double x, double y) { }
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
boolean eq = p1.equals(p2); // true: structural equality
Records are the conventional Java mechanism for immutable data carriers. Combined with the streams API and pattern matching, they admit a substantial functional style:
public record Person(String name, int age) { }
List<Person> people = List.of(
new Person("Alice", 30),
new Person("Bob", 28),
new Person("Carol", 35)
);
Map<Boolean, List<Person>> byAge = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 30));
double avgAge = people.stream()
.mapToInt(Person::age)
.average()
.orElse(0.0);
For genuinely persistent data structures, third-party libraries (Vavr, PCollections) provide structurally-shared immutable collections. The standard library’s List.of, Map.of, Set.of produce immutable collections without structural sharing.
Pure-function discipline
Java does not enforce purity; functions may freely modify global state, perform I/O, and read clocks. Nonetheless, the discipline of writing pure functions has substantial benefits:
- Pure functions are easier to test (no fixtures, no mocks).
- Pure functions are easier to reason about.
- Pure functions admit memoisation and parallelisation.
- Pure functions compose freely.
The conventional discipline:
- Take all inputs as parameters; do not read global state.
- Return all outputs; do not mutate parameters.
- Avoid I/O in computation functions; perform I/O at the boundaries.
public static double calculateTotal(List<Order> orders, double taxRate) {
return orders.stream()
.mapToDouble(Order::amount)
.sum() * (1 + taxRate);
}
The function is pure: same inputs yield same outputs; no side effects. Most stream-based code naturally fits this pattern.
Currying and partial application
Currying — converting (a, b) -> c into a -> b -> c — is not built-in but is straightforward:
public static <A, B, C> Function<A, Function<B, C>> curry(BiFunction<A, B, C> fn) {
return a -> b -> fn.apply(a, b);
}
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
Function<Integer, Function<Integer, Integer>> addCurried = curry(add);
Function<Integer, Integer> add5 = addCurried.apply(5);
int n = add5.apply(10); // 15
The construction is rare in idiomatic Java; the conventional pattern is to define a closure that captures the bound argument:
Function<Integer, Integer> add5 = x -> 5 + x; // simpler than curry
Partial application — supplying some arguments to produce a function of the rest — is similarly manual.
What Java does not have
The features that purely functional languages rely on, and their status in Java:
| Feature | Available? |
|---|---|
| First-class functions | Yes (lambdas, method references) |
| Closures | Yes (with effectively-final captures) |
| Higher-order functions | Yes (streams, functional interfaces) |
| Currying / partial application | Manual |
| Immutable data | Through records, immutable collections, final fields |
| Algebraic data types | Through sealed interfaces and records |
| Pattern matching | Yes (Java 16+; growing) |
| Tail-call optimisation | Implementation-defined; not guaranteed (HotSpot does not do it) |
| Persistent data structures | Through third-party libraries (Vavr, PCollections) |
| Type classes | No (interface dispatch is the substitute) |
| Monads | Manual (Optional, Stream, CompletableFuture admit monad-like usage through flatMap) |
do-notation | No |
| Lazy evaluation | Through streams and Supplier<T> |
| Higher-kinded types | No |
The combination of streams, records, sealed types, pattern matching, and Optional<T> covers most of the practical functional programming use cases in Java. Code that genuinely requires more (true persistence, dependent types, monad transformers) is typically written in Scala, Kotlin (with the Arrow library), or another JVM language and called from Java at a boundary.
Functional patterns vs OOP patterns
Many problems admit both an object-oriented solution (a class hierarchy with virtual dispatch) and a functional solution (a closed sum type with pattern-matching dispatch). The conventional Java advice:
- Use OOP when the set of operations is fixed and the set of types is open: many concrete types implement the same set of methods. Classic example: GUI widgets, plugins, drivers.
- Use functional/sealed when the set of types is fixed and the set of operations is open: a few concrete types, with new operations added without modifying the types. Classic example: AST nodes (the kinds are fixed; operations like type-checking, evaluation, pretty-printing are added independently).
- Use streams when the workload is collection processing: filter, map, reduce, group.
- Use lambdas when behaviour is parameterised: comparators, predicates, callbacks.
The choice is a design decision; modern Java admits both, and the conventional discipline is to pick the one that matches the axis of variation.
A note on Vavr and the broader ecosystem
The standard library’s functional surface is intentionally limited. Several third-party libraries provide richer functional facilities:
- Vavr (formerly Javaslang) — persistent collections,
Either<L, R>,Try<T>, currying, function composition, pattern matching. - FunctionalJava — pure-functional library inspired by Haskell.
- Arrow Java — functional DSL with effects, lenses, and typeclasses.
- Reactor / RxJava — reactive programming with functional operators.
For most application code the standard library suffices; for code that genuinely benefits from richer functional facilities, the libraries are well-maintained and widely used. The conventional advice is to start with the standard library and reach for the libraries when the standard’s surface becomes inadequate.