EL Lambdas, Collections & Streams
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Jakarta Expression Language 6.0 (API jakarta.el:jakarta.el-api 6.0.1), the version used by Jakarta
Pages 4.0 on Tomcat 11.0.26, with Jakarta Standard Tag Library 3.0 and Jakarta Servlet 6.1, on Java 21. Everything was
verified on 2026-10-08: the syntax and the semantics against sections 1.20 and 2 of the EL 6.0 specification and the
Javadoc, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs shown
are the outputs that run produced. Since Expression Language 3.0 (JSR 341, Java EE 7) the language has three features that
older JSP material does not know: lambda expressions (an anonymous function written inline), collection literals (a set,
a list or a map written inline) and stream operations (filter, map, sorted, reduce and nineteen more, applied to a
collection through a pipeline of method calls). Together they let a view answer questions such as "which books under ten
euros, cheapest first" or "how many authors are on this shelf" without a scriptlet, without a helper bean and without a
tag loop. They also make it easier than ever to put business logic in a view, so the page spends as much time on when not to
use them as on how. The running scenario is the Bookshelf, a small catalogue of books: a servlet puts a list of Book
records in the request and forwards to a view under WEB-INF, and each example below is a view of that one application. The
operators these examples use (lt, ge, and and the conditional operator) are explained on the Operators and Type Coercion page, and the Java equivalents of the same ideas are on the lambdas and method references,
streams and collectors and Optional pages.
The Bookshelf Used on This Page
All the examples are views of one WAR, deployed as g4ellambda.war under the context path /g4ellambda, built with the
Maven dependencies of the section’s example application (jakarta.servlet-api 6.1.0, jakarta.servlet.jsp-api 4.0.0 and
jakarta.el-api 6.0.1 as provided, and the JSTL API 3.0.2 with the GlassFish implementation 3.0.1). A servlet mapped to
/view/* puts the catalogue in the request and forwards to /WEB-INF/views/<name>.jsp. The Book record has seven
components, because the stream examples need a category, a price and a list of tags to work on.
package org.example.bookshelf;
import java.util.List;
public record Book(long id, String title, String author, int year, String category, double price, List<String> tags) {
}
package org.example.bookshelf;
import jakarta.el.LambdaExpression;
import java.util.List;
import java.util.function.Predicate;
public class Catalogue {
private final List<Book> books;
public Catalogue(List<Book> books) {
this.books = books;
}
public List<Book> getBooks() {
return books;
}
/** Receives the lambda as a LambdaExpression and invokes it itself. */
public List<Book> where(LambdaExpression predicate) {
return books.stream()
.filter(b -> Boolean.TRUE.equals(predicate.invoke(b)))
.toList();
}
/** Receives the lambda coerced to a functional interface (EL 5.0 and later). */
public List<Book> select(Predicate<Book> predicate) {
return books.stream().filter(predicate).toList();
}
}
@WebServlet("/view/*")
public class ViewServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String view = req.getPathInfo().substring(1);
List<Book> books = List.of(
new Book(1, "Dune", "Frank Herbert", 1965, "fiction", 9.50, List.of("scifi", "classic")),
new Book(2, "Neuromancer", "William Gibson", 1984, "fiction", 8.25, List.of("scifi", "cyberpunk")),
new Book(3, "The Selfish Gene", "Richard Dawkins", 1976, "science", 12.00, List.of("biology")),
new Book(4, "Cosmos", "Carl Sagan", 1980, "science", 14.90, List.of("astronomy", "classic")),
new Book(5, "Emma", "Jane Austen", 1815, "fiction", 6.00, List.of("classic", "romance")),
new Book(6, "Pale Blue Dot", "Carl Sagan", 1994, "science", 11.00, List.of("astronomy")));
req.setAttribute("books", books); // the six books above
req.setAttribute("catalogue", new Catalogue(books));
req.setAttribute("emptyBooks", new ArrayList<Book>()); // an empty list
req.setAttribute("words", List.of("pear", "fig", "apple", "kiwi", "plum"));
req.setAttribute("sizes", new int[] {3, 1, 2}); // a primitive array
req.setAttribute("titles", new String[] {"Dune", "Emma", "Cosmos"});
req.setAttribute("sink", new ArrayList<String>()); // collects side effects, see "Laziness"
req.getRequestDispatcher("/WEB-INF/views/" + view + ".jsp").forward(req, resp);
}
}
Every JSP listing below omits the page directive and the two taglib directives that start each file
(<%@ taglib prefix="c" uri="jakarta.tags.core" %> and <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>). Most views
use contentType="text/plain" so that the output can be shown exactly; the views that produce HTML say so. Because the
output is plain text, none of the listings escapes anything; in a real HTML page every value that can contain user text goes
through c:out, as the JSTL and worked-example sections show.
Where These Features Live
The three features are defined in two places of the Expression Language specification, and the split explains several behaviours that surprise people.
| Feature | Specification | How it is implemented |
|---|---|---|
Lambda expression |
EL 6.0 section 1.20 |
part of the language grammar: the parser builds a |
Set, list and map literals |
EL 6.0 sections 2.2.1 to 2.2.3 |
part of the language grammar: evaluated to a |
Stream operations |
EL 6.0 sections 2.3.5 to 2.3.26 |
not part of the language: ordinary method calls on the object |
Assignment and sequence ( |
EL 6.0 sections 1.13 and 1.14 |
part of the language grammar; used below to give a lambda a name |
Calling a lambda from Java |
EL 6.0 sections 1.25.8 and the |
a lambda passed to a Java method is either received as a |
In Tomcat 11.0.26 (observed) the stream classes are org.apache.el.stream.Stream and org.apache.el.stream.Optional, and the
only resolver involved is the one that answers stream() on a collection or an array (StreamELResolverImpl); every other
call on the stream, such as filter(...) or toList(), is an ordinary method call that the BeanELResolver makes on that
Stream object. Other containers carry their own implementation (GlassFish uses Expressly, Maven coordinates
org.glassfish.expressly:expressly 6.0.0), so the printed class names and the handling of the cases the specification calls
"undefined" can differ; the tables and examples on this page are the specified behaviour, and every row marked "observed" is
Tomcat’s.
Lambda Expressions
A lambda expression is a ValueExpression with parameters. The syntax is the Java syntax with one difference: the body is
not a block or a statement but an EL expression, so there is no return and no { }.
| Form | Example | Notes |
|---|---|---|
One parameter |
|
the parentheses are optional if and only if there is exactly one parameter |
Several parameters |
|
parentheses and commas required; |
No parameter |
|
an empty pair of parentheses |
Nested |
|
a lambda whose body is a lambda; called as |
Method reference |
not supported |
Java’s |
Block body or |
not supported |
the body is a single expression; use the conditional operator |
Calling a Lambda: Immediately, by Name, or from Java
A lambda can be invoked at once by following it with an argument list, it can be given a name with the assignment operator
and invoked through that name, or it can be passed as an argument to a method that invokes it. The assignment form is written
name = lambda; name(args): the semicolon operator evaluates its left side, discards the value and returns the right
side, and the assignment stores the lambda in a variable. On a JSP page the variable is a page-scope attribute, so it is
visible to everything later on the same page (see the scope pitfall below).
immediate: ${((x,y)->x+y)(3,4)}
one parameter: ${(x->x*2)(21)}
no parameters: ${(()->64)()}
assigned: ${add = (x,y)->x+y; add(3,4)}
reads a bean: ${(b->b.price lt 9)(books[0])}
as a value: ${(x->x).getClass().name}
immediate: 7 one parameter: 42 no parameters: 64 assigned: 7 reads a bean: false as a value: jakarta.el.LambdaExpression
The last line shows what a lambda is at run time: an instance of jakarta.el.LambdaExpression, an ordinary object that can
be stored, passed around and invoked. The line "reads a bean" calls a lambda with a Book; a record works as a bean
because the RecordELResolver is part of the default resolver chain since EL 6.0.
Recursion, Currying and Functions That Return Functions
Because a lambda is an object and the name it is assigned to is an ordinary variable, the lambda body can refer to its own name (recursion), can return another lambda (currying), and can take a lambda as a parameter (higher-order functions). The specification’s own examples are the factorial and the curried addition.
factorial: ${fact = n -> n==0 ? 1 : n*fact(n-1); fact(5)}
curried: ${add = x->y->x+y; add(10)(5)}
composed: ${inc = x->x+1; inc(inc(1))}
higher order: ${by = f->(p,q)->f(p).compareTo(f(q)); books.stream().sorted(by(b->b.title)).map(b->b.title).toList()}
factorial: 120 curried: 15 composed: 3 higher order: [Cosmos, Dune, Emma, Neuromancer, Pale Blue Dot, The Selfish Gene]
The last line is the specification’s comparing idea (section 2.3.9) made usable: by takes a function f and returns a
comparator, so sorted(by(b->b.title)) sorts by title. The inner lambda (p,q)->... still sees f after by has
returned, which is why the specification says that the scope of an outer lambda parameter extends to cover the inner body.
Scope, Hiding and Argument Counts
The scope of a lambda parameter is the lambda body. A parameter hides an EL variable or an attribute of the same name and the
parameters of enclosing lambdas of the same name. The reverse is a trap: assigning with = creates a page-scope attribute
that outlives the expression.
${x = 5; (x->x+1)(10)}
x is still ${x}
${(x->x)(7)}
extra arguments: ${((x)->x)(1,2,3)}
nested: ${[1,2,3].stream().map(a->[a, [10,20].stream().map(b->a*b).toList()]).toList()}
11 x is still 5 7 extra arguments: 1 nested: [[1, [10, 20]], [2, [20, 40]], [3, [30, 60]]]
Line one assigns x = 5 (which creates a page-scope attribute) and then calls a lambda whose own parameter is also called
x; the parameter wins inside the lambda and the result is 11. Line two shows that the attribute x is still 5 afterwards,
which is why a name assigned in one expression can silently change the meaning of a later one, and why assigned lambdas
should get names that nothing else on the page uses. The "extra arguments" line shows a rule of the specification: the
number of arguments must be equal to or more than the number of parameters and any extra arguments are ignored. Too few is
an error, and so is a second argument list on a lambda that does not return a lambda.
| Expression | Result |
|---|---|
|
|
|
|
|
parse error: |
Lambdas Passed to Java Methods
A lambda becomes useful to Java code when it is an argument of a method call. There are two ways for the receiving method to
accept it. If the parameter type is jakarta.el.LambdaExpression, the method receives the lambda itself and calls
invoke(Object...); this is how the where method of Catalogue above works. Since EL 5.0 (specification section 1.25.8)
the lambda can also be coerced to a functional interface, so a method that takes a java.util.function.Predicate or a
java.util.Comparator can be called with a lambda and the container produces the interface implementation. The
specification says a type counts as a functional interface only if it carries @FunctionalInterface; Tomcat 11.0.26
(observed) also accepts an interface with a single abstract method that does not carry the annotation, so do not rely on the
difference.
where: ${catalogue.where(b->b.year lt 1970).stream().map(b->b.title).toList()}
select: ${catalogue.select(b->b.category == 'science').stream().map(b->b.title).toList()}
removeIf: ${list = [3,1,2]; list.removeIf(n->n gt 1); list}
sort: ${list = [3,1,2]; list.sort((a,b)->a.compareTo(b)); list}
where: [Dune, Emma] select: [The Selfish Gene, Cosmos, Pale Blue Dot] removeIf: [1] sort: [1, 2, 3]
where receives a LambdaExpression, select receives a Predicate<Book>, and removeIf and sort are methods of the
list that EL calls with a lambda coerced to Predicate and Comparator. These two last calls mutate the list literal, which
is legal (see the next section) but is the opposite of what a view should do to a model object. On the list words, which
is immutable (List.of), words.sort(...) throws UnsupportedOperationException (observed).
A trap sits in the coerced comparator. The comparator must return an int, and EL arithmetic on integers produces a Long:
list.sort((a,b)->a-b) fails with ClassCastException: class java.lang.Long cannot be cast to class java.lang.Integer
(observed). Call a.compareTo(b), which returns an Integer, as the listing does. The stream operations are lenient about
this (their comparator result is coerced with the EL rules), which is why the arithmetic-difference style works with
sorted, but it has its own trap, described under sorting.
Collection Literals: Sets, Lists and Maps
EL can build a set, a list or a map inline. Every element can be any EL expression, not just a literal, and the elements are evaluated when the construction is evaluated. The three forms are told apart by their brackets and, for a map, by the colon.
| Kind | Syntax | Example | Result type on Tomcat 11.0.26 (observed) |
|---|---|---|---|
Set |
|
|
|
List |
|
|
|
Map |
|
|
|
Empty |
|
|
observed: |
set: ${{1,2,3}}
list: ${[1,"two",[3,4]]}
map: ${{"one":1,"two":2,"three":3}}
classes: ${{1,2}.getClass().name} ${[1,2].getClass().name} ${{"a":1}.getClass().name}
element: ${[10,20,30][1]}
key: ${{"one":1,"two":2}["two"]} ${{"one":1,"two":2}.two}
nested: ${[{"a":1},{"a":2}][1].a}
computed: ${[books[0].title, books[1].year + 1, {"price": books[2].price}]}
numeric keys: ${{1:"x",2:"y"}[1]}
empty: ${[]} ${{}} ${{}.getClass().name}
size: ${{1,1,2}.size()}
empty op: ${empty []} ${empty [1]}
length: ${fn:length([1,2,3])}
set: [1, 2, 3]
list: [1, two, [3, 4]]
map: {one=1, two=2, three=3}
classes: java.util.HashSet java.util.ArrayList java.util.HashMap
element: 20
key: 2 2
nested: 2
computed: [Dune, 1985, {price=12.0}]
numeric keys: x
empty: [] [] java.util.HashSet
size: 2
empty op: true false
length: 3
Four details of that output matter in practice.
-
A set is a
HashSet(observed), so its iteration order is whatever the hash codes give;{1,1,2}has two elements because a set drops duplicates. Do not rely on the order of a set literal; sort it through a stream if the order is part of the output. -
The braces of a literal collide with the braces that delimit the expression, so a set or map literal at the start of an expression is written
${{1,2,3}}: the outer pair is the EL delimiter and the inner pair is the literal. -
A map literal supports both access forms of a map:
m["two"]andm.two. Keys are expressions, so{1:"x"}[1]uses a number as a key. -
The literals are mutable
ArrayList,HashSetandHashMapobjects (observed:l = [1,2]; l.add(3); lis[1, 2, 3]), so a lambda can change them, but they are also fresh objects on every evaluation, so a change does not survive the expression unless the literal was assigned to a name.
A literal also works wherever EL is accepted, including tag attributes, which makes the items attribute of c:forEach
easy to feed without a bean:
<c:forEach var="t" items="${['alpha','beta','gamma']}">[${t}]</c:forEach>
<c:forEach var="e" items="${{'a':1,'b':2}.entrySet()}">${e.key}=${e.value};</c:forEach>
[alpha][beta][gamma] a=1;b=2;
The map iterates as Map.Entry objects, so e.key and e.value read each entry. A literal can also be passed to a
function such as fn:length, and combined with the empty operator (empty [] is true).
Streams and Pipelines
The collection operations are method calls on a stream of the collection’s elements. The model is the Java 8 model with a smaller scope: a pipeline has a source, zero or more intermediate operations (each returns another stream) and exactly one terminal operation (it does not return a stream). Nothing runs until the terminal operation starts.
list.stream()
array.stream()"] --> i1["Intermediate
filter, map, flatMap
distinct, sorted, peek
limit, substream"] i1 --> i2["more intermediate
operations
(zero or more)"] i2 --> term["Terminal
toList, toArray, reduce
max, min, average, sum, count
anyMatch, allMatch, noneMatch
findFirst, forEach, iterator"] term --> res["Result
a List, an array, a number
an Optional or null"]
Getting a Stream
stream() is available on any java.util.Collection and on a Java array. A Map has no stream(); its collection views
(entrySet(), values(), keySet()) do. The listing runs stream() on a list, a primitive array, a String array, the
paramValues implicit object (an array of strings per parameter name), a list literal, a set literal and the three views of
a map literal. The view is requested as /g4ellambda/view/x-sources?tag=a&tag=b.
list: ${books.stream().map(b->b.title).limit(2).toList()}
int array: ${sizes.stream().sorted().toList()}
String array: ${titles.stream().map(t->t.length()).toList()}
parameters: ${paramValues.tag.stream().map(t->t.toUpperCase()).toList()}
list literal: ${[3,1,2].stream().sorted().toList()}
set literal: ${{3,1,2}.stream().sorted().toList()}
entries: ${{"a":1,"b":2}.entrySet().stream().map(e->[e.key, e.value]).toList()}
values: ${{"a":1,"b":2}.values().stream().sum()}
keys: ${{"a":1,"b":2}.keySet().stream().sorted().toList()}
list: [Dune, Neuromancer] int array: [1, 2, 3] String array: [4, 4, 6] parameters: [A, B] list literal: [1, 2, 3] set literal: [1, 2, 3] entries: [[a, 1], [b, 2]] values: 3 keys: [a, b]
{"a":1}.stream() fails with MethodNotFoundException: Method not found: class java.util.HashMap.stream() (observed). A
stream of numbers made from a Java primitive array (sizes above) has Object elements as far as EL is concerned: EL deals
with objects and does not track generic types, and there are no IntStream or mapToInt.
The Rules of a Pipeline
The specification imposes five rules that the container does not enforce:
-
the operations must not modify the source collection, and the source must not be modified externally while the pipeline runs;
-
the behaviour is undefined if the collection contains
nullelements, so remove them first with afilter; -
a stream that has already started cannot be used in another pipeline;
-
any
nullargument raises aNullPointerException; -
the operations are specified on
Object, and generic types do not exist at run time.
Laziness and Order of Evaluation
Intermediate operations are lazy: filter does not build a filtered collection, it passes on one element at a time. The one
exception named by the specification is sorted, which must see all elements before it can yield the first. The listing
makes the order visible with peek, which calls a function on each element as it flows past; the function appends to the
request attribute sink, a list the servlet prepared. (Mutating a model object from a view is only acceptable in a
demonstration.)
${[1,2,3,4,5].stream().peek(i->sink.add('before '.concat(i))).filter(i-> i%2 == 0).peek(i->sink.add('after '.concat(i))).toList()}
${sink}
[2, 4] [before 1, before 2, after 2, before 3, before 4, after 4, before 5]
The trace shows each element going through the first peek, then the filter, and the even ones through the second peek,
before the next element starts. No intermediate collection is built. A limit after the filter ends the work early; the
peek before it then sees only the elements that were needed (verified separately: with limit(2) over [1..6] filtered
to values above 1, only the elements 1, 2 and 3 were visited).
A Stream Cannot Be Reused
A stream is a one-shot view. The specification calls a second use undefined; Tomcat does not complain, it quietly returns the answer for an exhausted stream.
${s = books.stream(); s.count() + s.count()}
${books.stream().count() + books.stream().count()}
6 12
The first line is 6 where 12 is expected, because the second count() runs on a consumed stream. The fix is to call
stream() again for every pipeline, as on the second line, or to keep the result list (toList()) and not the stream.
The Five Function Shapes
The operations take lambdas of five shapes. The shape names are descriptive, not types; the lambda is an ordinary
LambdaExpression and the container converts what it returns.
| Shape | Signature | Used by | What EL does with the result |
|---|---|---|---|
predicate |
|
|
coerces to |
mapper |
|
|
used as it is; for |
comparator |
|
|
coerced to a number and then to an |
consumer |
|
|
the return value is ignored |
binaryOperator |
|
|
the first argument is the accumulator, the second the current element; both must be of the type the lambda returns |
The Stream Operations: Reference
The specification defines 22 operations on a stream: eight intermediate and fourteen terminal, plus four methods on the
Optional several of them return. The table gives them all; the following sections demonstrate each one.
| Operation | Kind | Syntax | Result | On an empty stream |
|---|---|---|---|---|
|
intermediate |
|
stream of the elements the predicate accepts |
empty stream |
|
intermediate |
|
stream of the mapped values |
empty stream |
|
intermediate |
|
the mapped streams concatenated |
empty stream |
|
intermediate |
|
elements without duplicates according to |
empty stream |
|
intermediate |
|
elements in natural or comparator order; the source is untouched |
empty stream |
|
intermediate |
|
the same elements; the function runs on each (for debugging) |
empty stream |
|
intermediate |
|
the first |
empty stream |
|
intermediate |
|
skips |
empty stream |
|
terminal |
|
always |
nothing happens |
|
terminal |
|
a |
an iterator with no elements |
|
terminal |
|
an |
a zero-length array |
|
terminal |
|
a |
an empty list |
|
terminal |
|
an |
empty |
|
terminal |
|
|
empty |
|
terminal |
|
|
empty |
|
terminal |
|
|
empty |
|
terminal |
|
a |
zero |
|
terminal |
|
a |
zero |
|
terminal |
|
|
empty |
|
terminal |
|
|
empty |
|
terminal |
|
|
empty |
|
terminal |
|
|
empty |
Two rows differ from Java and are the usual source of bugs. anyMatch, allMatch and noneMatch return an Optional of a
boolean, not a boolean, and on an empty stream that Optional is empty (Java’s allMatch on an empty stream is true; EL’s
is empty). And there is no skip, collect, mapToInt, boxed, flatMapToInt, takeWhile, dropWhile, Collectors or
parallel stream: skip(n) is substream(n), and collect(groupingBy(...)) has no equivalent in the standard EL, so
grouping is written with distinct and filter as the report below does. Calling a name that does not exist fails with
MethodNotFoundException: Method not found: class org.apache.el.stream.Stream.skip(java.lang.Long) (observed).
Intermediate Operations
filter, map, flatMap and distinct
filter keeps the elements for which the predicate is true, map replaces each element by the value of the mapper, flatMap
replaces each element by a stream and joins them, and distinct removes duplicates by equals.
filter: ${books.stream().filter(b->b.price ge 10).map(b->b.title).toList()}
two tests: ${books.stream().filter(b->b.year ge 1980 and b.category eq "science").map(b->b.title).toList()}
map: ${books.stream().map(b->b.year).toList()}
pairs: ${books.stream().filter(b->b.price ge 12).map(b->[b.title, b.price]).toList()}
flatMap: ${books.stream().flatMap(b->b.tags.stream()).distinct().sorted().toList()}
letters: ${words.stream().flatMap(w->w.toCharArray().stream()).sorted().distinct().toList()}
distinct: ${books.stream().map(b->b.author).distinct().count()} authors
array: ${['a','b','b','c'].stream().distinct().toArray().length} elements
filter: [The Selfish Gene, Cosmos, Pale Blue Dot] two tests: [Cosmos, Pale Blue Dot] map: [1965, 1984, 1976, 1980, 1815, 1994] pairs: [[The Selfish Gene, 12.0], [Cosmos, 14.9]] flatMap: [astronomy, biology, classic, cyberpunk, romance, scifi] letters: [a, e, f, g, i, k, l, m, p, r, u, w] distinct: 5 authors array: 3 elements
flatMap is the operation behind the "all tags of all books" line: each book contributes the stream of its tags, and
distinct().sorted() turns the six concatenated tag streams into a sorted list without duplicates. The "letters" line is
the specification’s own flatMap example (the characters of a list of words, via toCharArray() which is itself a source).
The "two tests" line shows that the lambda body is a full EL expression: and and eq work inside it as anywhere else. A
mapper that builds a list literal ([b.title, b.price]) is the usual way to produce pairs.
sorted, limit and substream
sorted() uses the natural order and needs Comparable elements; sorted(comparator) takes a lambda of two arguments. The
specification warns that an element type that does not implement Comparable makes the no-argument form undefined; Tomcat
throws ELException caused by ClassCastException: class org.example.bookshelf.Book cannot be cast to class java.lang.Comparable
(observed), and a null element gives an ELException caused by a NullPointerException. limit(n) keeps the first n
elements. substream(start) and substream(start, end) skip the first start elements and then take the rest or the
elements up to end exclusive; it is the EL name for skip, and with limit it is how paging is written.
natural: ${[1,3,2,4].stream().sorted().toList()}
reversed: ${[1,3,2,4].stream().sorted((i,j)->j-i).toList()}
by length: ${words.stream().sorted((s,t)->(s.length()==t.length() ? s.compareTo(t) : s.length() - t.length())).toList()}
by year: ${books.stream().sorted((p,q)->p.year-q.year).map(b->b.title).toList()}
by price: ${books.stream().sorted((p,q)->p.price.compareTo(q.price)).limit(3).map(b->b.title).toList()}
limit: ${[1,2,3].stream().limit(0).toList()} ${[1,2,3].stream().limit(10).toList()} ${[1,2,3].stream().limit(-1).toList()}
substream: ${[1,2,3,4,5].stream().substream(2,4).toList()} ${[1,2,3,4,5].stream().substream(3).toList()}
out of range: ${[1,2,3].stream().substream(7).toList()} ${[1,2,3].stream().substream(-2).toList()}
a page: ${books.stream().substream(2,4).map(b->b.title).toList()}
natural: [1, 2, 3, 4] reversed: [4, 3, 2, 1] by length: [fig, kiwi, pear, plum, apple] by year: [Emma, Dune, The Selfish Gene, Cosmos, Neuromancer, Pale Blue Dot] by price: [Emma, Neuromancer, Dune] limit: [] [1, 2, 3] [] substream: [3, 4] [4, 5] out of range: [] [1, 2, 3] a page: [The Selfish Gene, Cosmos]
Reading the output: "by length" is the specification’s own example of a comparator that falls back to alphabetical order
for words of equal length; "by price" sorts ascending by Double.compareTo and keeps the three cheapest; the three limit
calls show zero giving an empty list, a count larger than the stream giving everything and a negative count giving an empty
list; "out of range" shows a start beyond the end giving an empty list and a negative start skipping nothing; "a page" is
page two of a page size of two (elements at positions 2 and 3).
Two traps are specific to comparators.
-
A comparator built with subtraction truncates decimals. The comparator result is coerced to an
int, so with prices1.5,1.2and1.0the differences are0.3and0.2, which become 0 and the list is returned unsorted (observed:[1.5,1.2,1.0].stream().sorted((a,b)->a-b).toList()prints[1.5, 1.2, 1.0]). Usea.compareTo(b)for decimals; the subtraction form is safe only for integers, as in the by-year line above. -
A one-parameter lambda is not a comparator. The specification’s own examples for
sortedandlimitpassp->p.unitPrice(a key extractor in the style of Java’sComparator.comparing), which EL does not have. On Tomcat the call does not fail: the lambda is called with two arguments, the extra one is ignored and the returned price is read as a comparison result, so the order is arbitrary (observed:books.stream().sorted(p->p.price).limit(1).map(b->b.title).toList()printed[Dune], the first book in the catalogue, not the cheapest). Write the comparator with two parameters, or buildcomparingas in the recursion section.
peek
peek passes every element on unchanged and calls a function on each, as the laziness listing showed. The specification
describes its purpose as debugging. In a JSP there is no print function (the specification’s example assumes one;
the call print(i) fails with Function [:print] not found, observed), so the listings call sink.add instead; in a
real page, prefer no side effect at all.
Terminal Operations
forEach, iterator, toArray and toList
forEach calls a consumer on every element and always returns null, which prints as nothing. iterator() returns a Java
Iterator, which the specification describes as "suitable for use in Java codes". toArray() returns an
Object[] (not a Book[]) and toList() returns a List.
forEach: [${books.stream().forEach(b->sink.add(b.id))}] ${sink}
ifPresent: [${books.stream().findFirst().ifPresent(b->sink.add(b.title))}] ${sink}
iterator: ${it = books.stream().map(b->b.id).iterator(); it.next()} ${it.next()} ${it.hasNext()}
toArray: ${words.stream().toArray().length} ${words.stream().toArray()[1]}
toList: ${words.stream().toList().getClass().name}
forEach: [] [1, 2, 3, 4, 5, 6] ifPresent: [] [1, 2, 3, 4, 5, 6, Dune] iterator: 1 2 true toArray: 5 fig toList: java.util.ArrayList
The array is usable like any array: .length is the EL 6.0 array property, an index reads an element, and c:forEach and
fn:join accept it. A stream itself prints as org.apache.el.stream.Stream@... (observed), which is the sign that a
terminal operation is missing.
reduce, max, min, average, sum and count
reduce folds the elements with a binary operator. With a seed the seed is the first accumulator and the result is the
value; without a seed the first element is the seed and the result is an Optional (empty if the stream is empty). max
and min take an optional comparator; without one the elements must be Comparable. average and sum coerce the
elements to numbers with the EL rules, so ["1","2"].stream().sum() is 3 (observed) and a non-numeric string raises
ELException caused by NumberFormatException. count returns a Long.
reduce: ${[1,2,3,4].stream().reduce((a,b)->a+b).get()}
with seed: ${[1,2,3,4].stream().reduce(10,(a,b)->a+b)}
empty: ${emptyBooks.stream().reduce((a,b)->a).orElse('none')}
dearest: ${books.stream().reduce((p,q)->p.price gt q.price ? p : q).get().title}
max: ${books.stream().map(b->b.year).max().get()}
max by: ${books.stream().max((p,q)->p.price.compareTo(q.price)).get().title}
min: ${books.stream().map(b->b.year).min().get()}
min by: ${books.stream().min((p,q)->p.year-q.year).get().title}
average: ${books.stream().map(b->b.price).average().get()}
sum: ${books.stream().map(b->b.price).sum()} ${[1,2,3].stream().sum()} ${[1,2,3].stream().sum().getClass().name}
sum, empty: ${emptyBooks.stream().sum()}
count: ${books.stream().filter(b->b.category == 'science').count()} ${books.stream().count().getClass().name}
strings: ${["1","2"].stream().sum()}
reduce: 10 with seed: 20 empty: none dearest: Cosmos max: 1994 max by: Cosmos min: 1815 min by: Emma average: 10.275 sum: 61.65 6 java.lang.Long sum, empty: 0 count: 3 java.lang.Long strings: 3
The "dearest" line is the specification’s reduce example (find the tallest student) written correctly for books; the
specification’s printed version has unbalanced parentheses and its .get() is in the wrong place. reduce needs the
operator to return the type of the elements: reduce((p,q)->p.price+q.price) over books fails with
PropertyNotFoundException: Property [price] not found on type [java.lang.Double] (observed), because the second call passes
a Double as the accumulator; map to the prices first (map(b->b.price).sum()). sum() of a stream of Double is
a Double (61.65 here, with whatever floating-point rounding double addition gives), and for integers a Long.
anyMatch, allMatch, noneMatch and findFirst
The three matchers and findFirst return an Optional, so the call that reads the answer is one of the Optional methods
(next section). The listing shows the pattern that is almost always right: finish with orElse(false), or get() when the
stream cannot be empty.
anyMatch: ${books.stream().anyMatch(b->b.year lt 1900).get()}
allMatch: ${books.stream().allMatch(b->b.price gt 5).get()}
noneMatch: ${books.stream().noneMatch(b->b.category == 'poetry').get()}
empty: ${emptyBooks.stream().anyMatch(b->true).orElse('empty stream')}
findFirst: ${books.stream().findFirst().get().title}
first match: ${books.stream().filter(b->b.category == 'science').findFirst().get().title}
none: ${emptyBooks.stream().findFirst().orElse('nothing')}
orElseGet: ${emptyBooks.stream().findFirst().orElseGet(()->'generated')}
as a test: ${books.stream().anyMatch(b->b.year gt 2000).orElse(false) ? "yes" : "no"}
anyMatch: true allMatch: true noneMatch: true empty: empty stream findFirst: Dune first match: The Selfish Gene none: nothing orElseGet: generated as a test: no
What an Empty Stream Gives
Every terminal operation has a defined answer for a stream without elements, and several differ from Java’s.
filter: ${emptyBooks.stream().filter(b->true).toList()}
count: ${emptyBooks.stream().count()}
sum: ${emptyBooks.stream().sum()}
reduce: ${emptyBooks.stream().reduce((a,b)->a).orElse('empty Optional')}
with a seed: ${emptyBooks.stream().reduce(10,(a,b)->a+b)}
max: ${emptyBooks.stream().max().orElse('empty Optional')}
average: ${emptyBooks.stream().average().orElse('empty Optional')}
allMatch: ${emptyBooks.stream().allMatch(b->false).orElse('empty Optional')}
findFirst: ${emptyBooks.stream().findFirst().orElse('empty Optional')}
filter: [] count: 0 sum: 0 reduce: empty Optional with a seed: 10 max: empty Optional average: empty Optional allMatch: empty Optional findFirst: empty Optional
The Optional Returned by Terminal Operations
A value that may not exist is wrapped in an Optional, EL’s own small class (not java.util.Optional; the specification
lists only its methods, and Tomcat’s class is org.apache.el.stream.Optional). The four methods of the specification are:
get() (the value, or an ELException if empty), ifPresent(consumer) (calls the function when a value exists),
orElse(other) (the value or a default) and orElseGet(() -> other) (the value or the result of a lambda).
| Method | Behaviour | On an empty Optional |
|---|---|---|
|
returns the value |
|
|
calls the consumer with the value |
nothing happens and the call returns |
|
returns the value, or |
returns |
|
returns the value, or the value of the lambda |
calls the lambda |
An Optional never converts itself to its content. Printed, it is an object, and used where a boolean is expected it is an
error:
${books.stream().findFirst()}
${books.stream().filter(b->b.year gt 1970).map(b->b.year).min().get()}
org.apache.el.stream.Optional@640e8b03 1976
<c:if test="${books.stream().anyMatch(b->b.year lt 1900)}"> and ${books.stream().anyMatch(b->b.year lt 1900) ? 1 : 2}
fail with ELException: Cannot convert [org.apache.el.stream.Optional@...] of type [class org.apache.el.stream.Optional] to [class java.lang.Boolean]
(observed). This is not the EL 6.0 java.util.Optional support (the OptionalELResolver, which is opt-in and covered on the Static Access, Records and Optional page); the stream Optional is a different class that no resolver unwraps.
or orElseGet(lambda)"] q -->|"a boolean test"| ob["anyMatch / allMatch / noneMatch
then orElse(false)"] q -->|"do something if present"| ip["ifPresent(lambda)"] g -.->|"empty Optional"| ex["ELException"]
Maps and Streams
A map is processed through its views. entrySet() gives Map.Entry elements with the properties key and value,
values() and keySet() give plain collections. Because every view is a Collection, every operation above applies.
big values: ${{"a":1,"b":2}.entrySet().stream().filter(e->e.value gt 1).map(e->e.key).toList()}
sorted keys: ${{"b":2,"a":1}.keySet().stream().sorted().toList()}
sum of values: ${{"a":1,"b":2}.values().stream().sum()}
entry class: ${{"a":1}.entrySet().stream().findFirst().get().getClass().name}
big values: [b] sorted keys: [a, b] sum of values: 3 entry class: java.util.HashMap$Node
Pipelines Inside JSTL Tags
A pipeline is an EL expression, so it works in every attribute that takes EL. The examples below do the selection in EL and
leave the rendering and the escaping to the tags: c:set stores the sorted list, c:forEach renders it with c:out,
c:if takes the boolean from orElse(false), and toArray() feeds c:forEach directly because the tag accepts an array.
A stream is not accepted: c:forEach items="${books.stream()}" fails with JspTagException: Don't know how to iterate over supplied "items" in <forEach> (observed).
<c:set var="cheap" value="${books.stream().filter(b->b.price lt 10).sorted((p,q)->p.price.compareTo(q.price)).toList()}"/>
<ul>
<c:forEach var="b" items="${cheap}"><li><c:out value="${b.title}"/> ${b.price}</li>
</c:forEach></ul>
<c:if test="${books.stream().anyMatch(b->b.year lt 1900).orElse(false)}">the shelf holds a pre-1900 book</c:if>
<c:forEach var="b" items="${books.stream().filter(b->b.price lt 7).toArray()}">cheapest: <c:out value="${b.title}"/></c:forEach>
total: <c:out value="${books.stream().map(b->b.price).sum()}"/>
<ul>
<li>Emma 6.0</li>
<li>Neuromancer 8.25</li>
<li>Dune 9.5</li>
</ul>
the shelf holds a pre-1900 book
cheapest: Emma
total: 61.65
Worked Example: The Shelf Report
The Bookshelf wants a small report with one row per category: how many books, their total price, the dearest title and the
authors. There is no standard groupingBy, so the rows come from a pipeline over the distinct categories, and for each
category a second pipeline over the books. Every piece of text that is data goes through c:out.
<table>
<c:forEach var="cat" items="${books.stream().map(b->b.category).distinct().sorted().toList()}">
<c:set var="inCat" value="${books.stream().filter(b->b.category == cat).toList()}"/>
<tr><td><c:out value="${cat}"/></td>
<td>${inCat.size()}</td>
<td>${inCat.stream().map(b->b.price).sum()}</td>
<td><c:out value="${inCat.stream().max((p,q)->p.price.compareTo(q.price)).get().title}"/></td>
<td><c:out value="${fn:join(inCat.stream().map(b->b.author).distinct().sorted().toArray(), ', ')}"/></td></tr>
</c:forEach>
</table>
<table>
<tr><td>fiction</td>
<td>3</td>
<td>23.75</td>
<td>Dune</td>
<td>Frank Herbert, Jane Austen, William Gibson</td></tr>
<tr><td>science</td>
<td>3</td>
<td>37.9</td>
<td>Cosmos</td>
<td>Carl Sagan, Richard Dawkins</td></tr>
</table>
The page stays readable because each pipeline fits on a line and does one job. When a view needs more than that, say two nested pipelines per row and a third for a total, the grouping belongs in the controller or in a bean method that returns the report rows; the view then iterates a list of rows and nothing else. The pipelines run in the view on every request, on a list the servlet already fetched, and cost one pass over the list each; they must never be used to hide a database query or a remote call.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
|
|
|
|
a call with fewer arguments than parameters |
pass every argument; extra arguments are ignored |
|
a second argument list on a lambda that returns a value, not a lambda |
curry the lambda ( |
An assigned lambda or value changes a later expression |
|
unique names, or no assignment; prefer |
|
|
|
|
an operation that EL does not have: |
|
The page prints |
no terminal operation |
end with |
The page prints |
a terminal operation that returns an |
|
|
|
|
|
|
|
|
a |
return a boolean: |
|
a typo in a bean or record property inside a lambda |
check the component name; records need EL 6.0 |
|
|
pass a comparator, or |
The count is half what it should be, or a pipeline looks empty the second time (observed: 6 instead of 12) |
a stream was reused |
call |
|
the difference is coerced to an |
|
|
a key extractor is not a comparator; the specification’s own examples contain this error |
a two-parameter comparator |
|
a lambda coerced to a Java |
|
|
a mutating method ( |
never mutate model data in a view; sort with |
|
a |
|
Unexpected values, no error (observed) |
|
filter nulls; make the mapper return |
A user’s text is shown unescaped |
EL does not escape; a pipeline result is printed as it is |
|
An expression built from request data ( |
EL injection: a lambda or a method call can reach any object the resolvers expose |
never build an expression string from input; evaluate only expressions written in the page |
The view starts to look like a program |
lambdas, assignments and three nested pipelines in one page |
move the logic to the controller or a bean; keep one short pipeline per output |
What Changed Recently
| Version | Change |
|---|---|
EL 3.0 (JSR 341, Java EE 7) |
the first version that was an independent specification; introduced lambda expressions, collection construction and the stream operations of this page, together with the assignment, semicolon and string concatenation operators and static field and method access. Before it, EL 2.2 had method calls (JSP 2.2) but no inline functions |
EL 4.0 (Jakarta EE 9) |
the namespace changes from |
EL 5.0 (Jakarta EE 10) |
a |
EL 6.0 (Jakarta EE 11) |
minimum Java 17; the |
EL 6.1 (Jakarta EE 12, under development) |
the plan on the specification page lists an Elvis operator and |
Where the Books Are Outdated
None of the books in this section knows these features. Web Development with JavaServer Pages (2nd edition, 2002) is
written for JSP 1.2, which has no expression language at all, and JSTL in Action (2003) teaches the JSTL 1.0 expression
language, which was a small language of property access, comparison, arithmetic and empty, available only inside JSTL tag
attributes. Both the lambdas and the streams of this page arrived eleven years later.
| The books' era | Today (EL 6.0, Pages 4.0, JSTL 3.0) |
|---|---|
Filtering a collection by writing a |
a |
Sorting with a scriptlet and |
|
Summing or averaging in a scriptlet loop or a custom tag |
|
A tag or a scriptlet to build a list or a map of constants |
|
Calling a bean method with a value computed by Java |
a lambda argument ( |
The JSTL 1.0 EL: no method calls, no lambdas, no assignment, no static access |
the unified EL of EL 3.0 to 6.0, usable everywhere in a page, including in template text |
|
not needed: EL works in every attribute; the JSTL 3.0 URIs are |
|
|
Treating EL as read-only (no side effects) |
assignment ( |
The assumption that EL cannot call static methods or build collections |
static access exists since EL 3.0 (see the page about static access, records and |
Source: Manning — JSTL in Action
Where to Go Next
The rest of the expression language is in sibling pages of this section: the
fundamentals (${...} versus #{...}, implicit objects, property access), the
operators and the coercion rules, static field and method access, enums, records and Optional,
and the configuration of EL in a container and the standalone ELProcessor. The Java side of the same concepts is on the lambdas and method references,
streams and collectors and Optional pages, where the
Java versions of filter, map, sorted, reduce and the collectors that EL lacks are explained in full.
References
-
Jakarta Expression Language 6.0 — LambdaExpression.invoke(Object…)
-
Jakarta Expression Language 6.0 — 1.25.8 Coerce A to functional interface method invocation
-
Jakarta Expression Language 6.0 — 2.2 Construction of Collection Objects
-
Jakarta Expression Language 6.0 — 2.3.1 Streams and Pipelines
-
Jakarta Expression Language 6.0 — 2.3.3 Implementation Classes
-
Jakarta Expression Language 6.0 — 2 Operations on Collection Objects
-
Jakarta Expression Language 6.0 — 2.3.2 Operation Syntax Description
-
Manning — Web Development with JavaServer Pages, Second Edition