intermediate20 min

Lambdas and the Stream API

Passing behavior as a value with lambdas, and chaining map/filter/reduce-style operations over a collection with Streams instead of hand-written loops.

What you'll learn

  • Write and pass a lambda expression as an argument
  • Chain a stream pipeline using filter, map, and a terminal operation
  • Explain why streams are lazy and why a stream can only be consumed once

Prerequisites

Explanation

A lambda expression ((course) -> course.getEnrollmentCount() > 10) is a compact way to write an implementation of a functional interface — an interface with exactly one abstract method — inline, without a separate named class. Predicate<Course>, Function<A, B>, and Consumer<T> from java.util.function are the standard functional interfaces you'll use constantly: a Predicate<T> takes a T and returns boolean (used for filtering), a Function<A, B> takes an A and returns a B (used for transforming), and a Consumer<T> takes a T and returns nothing (used for side effects like printing). A lambda is just a value — you can store it in a variable, pass it as a method argument, or return it from a method, exactly like any other object.

The Stream API builds on lambdas to let you express a pipeline of operations over a collection declaratively, instead of a hand-written loop with a mutable accumulator: courses.stream().filter(c -> c.isPublished()).map(Course::getTitle).sorted().toList() reads left to right as "take the courses, keep only published ones, get each one's title, sort them, collect into a List" — and that's exactly what it does, with no explicit loop variable, no manually-managed accumulator, and no risk of an off-by-one index bug. filter and map are intermediate operations — they don't run anything by themselves, they just describe a step and return a new Stream; a stream pipeline only actually executes once a terminal operation (.toList(), .count(), .forEach(...), .reduce(...)) is called. This is called laziness, and it means writing .filter(...).map(...) with no terminal operation at the end does nothing at all — a common source of confusion for people new to streams, who write a pipeline, run it, and see no effect, because they never called a terminal operation.

A Stream can be consumed only once — calling a second terminal operation on the same stream throws IllegalStateException, because a stream isn't a data structure you can query repeatedly like a List; it's closer to a one-time, single-pass description of a computation. If you need to run two different pipelines over the same data, start from .stream() again on the original collection each time. This maps directly onto the same real, everyday operations Array.prototype.filter/map/reduce already give you in JavaScript — this lesson's exercises are, deliberately, close to a direct translation, since the underlying idea (transform a sequence declaratively, without a hand-managed loop) is genuinely the same one.

Example

The exact filter -> map -> collect pipeline shape Java Streams use, written with JS's real Array methods -- these ARE the direct analogue, not a simplification.

const courses = [
  { title: "Java Basics", published: true, enrollments: 40 },
  { title: "Advanced Java", published: false, enrollments: 5 },
  { title: "Java for Testing", published: true, enrollments: 12 },
];

const publishedTitles = courses
  .filter(c => c.published)   // intermediate: keep only published courses
  .map(c => c.title)          // intermediate: transform to just the title
  .sort();                    // still just describing more steps, in JS this already runs eagerly per-call

console.log(publishedTitles); // ["Java Basics", "Java for Testing"]

// A lambda passed as a value, exactly like Java's Predicate<Course>:
const isPopular = c => c.enrollments > 10;
console.log(courses.filter(isPopular).map(c => c.title));

Try it yourself

Add a .reduce() call that sums every course's enrollments into a single total.

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Write publishedTitlesSorted(courses) modeling a Java stream pipeline: filter to published courses (course.published === true), map to course.title, and return the titles sorted alphabetically. Use filter/map/sort as a chained pipeline, not a hand-written loop.

Checks: filters, maps, and sorts a mixed dataset correctly · handles an empty input array

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write totalEnrollments(courses) modeling a Java stream's .mapToInt(Course::getEnrollments).sum() -- map each course to its enrollments field, then reduce to a single total using .reduce(). Then write mostPopularTitle(courses) returning the title of the course with the highest enrollments (throw an Error if courses is empty).

Checks: totalEnrollments sums correctly · totalEnrollments handles an empty list · mostPopularTitle finds the correct course · mostPopularTitle throws on empty input

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Writing `stream.filter(...)` with no terminal operation and expecting something to happen -- streams are lazy; nothing executes until a terminal operation like .toList() or .forEach() is called.
  • Trying to call a second terminal operation on the same stream (`.count()` then `.forEach()` on the same variable) -- a stream can only be consumed once and throws IllegalStateException on reuse; start a fresh .stream() call instead.
  • Using streams for everything, including simple cases where a plain for-each loop is more readable -- streams shine for genuine transform/filter/aggregate pipelines, not as a mandatory replacement for every loop.

Knowledge check

Knowledge check

1. What does it mean that Java Streams are 'lazy'?
2. What happens if you call two different terminal operations on the same Stream instance?
3. What kind of interface can a lambda expression implement?

Takeaway

Lambdas are values that implement a single-method interface inline; streams chain lazy intermediate operations (filter, map) that only run once a terminal operation triggers the pipeline, and a stream can be consumed exactly once.

Summary

A lambda implements a functional interface (Predicate, Function, Consumer, or your own). Stream pipelines chain filter/map (lazy, no effect alone) with a terminal operation (toList, reduce, forEach) that actually executes the pipeline. Each stream instance is single-use.

References

Your notes

Notes save automatically.