Polyglot
Languages Java functional
Java § functional

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:

OperationEffect
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:

FeatureAvailable?
First-class functionsYes (lambdas, method references)
ClosuresYes (with effectively-final captures)
Higher-order functionsYes (streams, functional interfaces)
Currying / partial applicationManual
Immutable dataThrough records, immutable collections, final fields
Algebraic data typesThrough sealed interfaces and records
Pattern matchingYes (Java 16+; growing)
Tail-call optimisationImplementation-defined; not guaranteed (HotSpot does not do it)
Persistent data structuresThrough third-party libraries (Vavr, PCollections)
Type classesNo (interface dispatch is the substitute)
MonadsManual (Optional, Stream, CompletableFuture admit monad-like usage through flatMap)
do-notationNo
Lazy evaluationThrough streams and Supplier<T>
Higher-kinded typesNo

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.