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.

Book, the record the views read (a record is a bean for EL 6.0 through RecordELResolver)
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) {
}
Catalogue, a bean with two methods that take a lambda (the Java side of the examples in the lambda section)
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();
    }
}
ViewServlet, the request attributes every view reads (imports and the view-name check omitted)
@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.

Table 1. Where each feature is specified and how it is implemented
Feature Specification How it is implemented

Lambda expression

EL 6.0 section 1.20

part of the language grammar: the parser builds a LambdaExpression object (jakarta.el.LambdaExpression, part of the API) which behaves like a function; invoke evaluates its body

Set, list and map literals

EL 6.0 sections 2.2.1 to 2.2.3

part of the language grammar: evaluated to a java.util.Set, java.util.List or java.util.Map at the moment the expression is evaluated

Stream operations

EL 6.0 sections 2.3.5 to 2.3.26

not part of the language: ordinary method calls on the object stream() returns. The specification says explicitly that the Stream and Optional classes are not part of the API and that the operations can be extended or replaced with customised ELResolver objects

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 LambdaExpression Javadoc

a lambda passed to a Java method is either received as a LambdaExpression or coerced to a functional interface (since EL 5.0)

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 { }.

Table 2. Forms of a lambda expression
Form Example Notes

One parameter

x->x+1 or (x)->x+1

the parentheses are optional if and only if there is exactly one parameter

Several parameters

(x,y)->x+y

parentheses and commas required; x,y->x+y is a parse error (observed)

No parameter

()->64

an empty pair of parentheses

Nested

x->y->x+y

a lambda whose body is a lambda; called as f(1)(2)

Method reference

not supported

Java’s String::length does not exist in EL; write s->s.length() (observed: "Failed to parse the expression")

Block body or return

not supported

the body is a single expression; use the conditional operator ?: and the sequence operator ; for more

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).

x-lambda-basics.jsp: five ways to call or hold a lambda
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}
Output
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.

x-lambda-recursion.jsp: recursion, currying, composition and a function that builds comparators
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()}
Output
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-lambda-scope.jsp: hiding, leaking assignments, extra arguments and nested lambdas
${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()}
Output
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.

Table 3. Errors raised by wrong argument lists (observed on Tomcat 11.0.26)
Expression Result

${((x,y)->x+y)(3)}

ELException: Only [1] arguments were provided for a lambda expression that requires at least [2]

${((x,y)->x+y)(1,2)(3)}

ELException: There are more sets of method parameters specified than there are nested lambda expressions

${(x,y->x+y)(1,2)}

parse error: ELException: Failed to parse the expression [...]

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.

x-lambda-java.jsp: a lambda as an argument of a bean method, and of methods of java.util.List
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}
Output
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.

Table 4. Collection construction syntax (EL 6.0 sections 2.2.1 to 2.2.3)
Kind Syntax Example Result type on Tomcat 11.0.26 (observed)

Set

{ expr, expr ... }

{1, 2, 3}

java.util.HashSet; the specification says Set<Object>

List

[ expr, expr ... ]

[1, "two", [3, 4]]

java.util.ArrayList; the specification says List<Object>

Map

{ key : value, ... }

{"one":1, "two":2}

java.util.HashMap; the specification says Map<Object,Object>

Empty

[] is an empty list, {} is an empty set

{} is never a map

observed: {} is a HashSet; there is no literal for an empty map

x-literals.jsp: every literal form, the classes behind them, and what can be done with them
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])}
Output
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"] and m.two. Keys are expressions, so {1:"x"}[1] uses a number as a key.

  • The literals are mutable ArrayList, HashSet and HashMap objects (observed: l = [1,2]; l.add(3); l is [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:

x-literals-use.jsp (contentType text/html): a list and a map literal as the items of c:forEach
<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>
Output
[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.

flowchart LR src["Source
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.

x-sources.jsp: every source of a stream
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()}
Output
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 null elements, so remove them first with a filter;

  • a stream that has already started cannot be used in another pipeline;

  • any null argument raises a NullPointerException;

  • 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.)

x-lazy.jsp: elements flow one by one through the pipeline
${[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}
Output
[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.

x-reuse.jsp: the same stream counted twice, and two fresh streams
${s = books.stream(); s.count() + s.count()}
${books.stream().count() + books.stream().count()}
Output
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.

Table 5. The function shapes of the stream operations (EL 6.0 section 2.3.4)
Shape Signature Used by What EL does with the result

predicate

S -> boolean

filter, anyMatch, allMatch, noneMatch

coerces to Boolean; a Double such as b->b.price fails: ELException: Cannot convert [9.5] of type [class java.lang.Double] to [class java.lang.Boolean] (observed)

mapper

S -> R

map, flatMap

used as it is; for flatMap the result must be a stream, otherwise the behaviour is undefined (observed: an empty list)

comparator

(S, S) -> int

sorted, max, min

coerced to a number and then to an int; a negative, zero or positive number

consumer

S -> void

forEach, peek, Optional.ifPresent

the return value is ignored

binaryOperator

(S, S) -> S

reduce

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.

Table 6. Every stream operation of EL 6.0 (section 2.3)
Operation Kind Syntax Result On an empty stream

filter

intermediate

filter(S -> boolean)

stream of the elements the predicate accepts

empty stream

map

intermediate

map(S -> R)

stream of the mapped values

empty stream

flatMap

intermediate

flatMap(S -> Stream)

the mapped streams concatenated

empty stream

distinct

intermediate

distinct()

elements without duplicates according to equals

empty stream

sorted

intermediate

sorted(), sorted((p,q)->int)

elements in natural or comparator order; the source is untouched

empty stream

peek

intermediate

peek(S -> void)

the same elements; the function runs on each (for debugging)

empty stream

limit

intermediate

limit(count)

the first count elements; zero or negative gives an empty stream

empty stream

substream

intermediate

substream(start), substream(start, end)

skips start elements, then takes the rest or end - start elements

empty stream

forEach

terminal

forEach(S -> void)

always null

nothing happens

iterator

terminal

iterator()

a java.util.Iterator

an iterator with no elements

toArray

terminal

toArray()

an Object[]

a zero-length array

toList

terminal

toList()

a java.util.List (an ArrayList on Tomcat)

an empty list

reduce

terminal

reduce((S,S)->S), reduce(seed, (S,S)->S)

an Optional without a seed, the value with one

empty Optional; with a seed, the seed

max

terminal

max(), max((p,q)->int)

Optional of the largest element; without a comparator the elements must be Comparable

empty Optional

min

terminal

min(), min((p,q)->int)

Optional of the smallest element

empty Optional

average

terminal

average()

Optional of the mean; elements coerced to numbers

empty Optional

sum

terminal

sum()

a Number (a Long for integers, a Double for decimals on Tomcat); elements coerced to numbers

zero

count

terminal

count()

a Long

zero

anyMatch

terminal

anyMatch(S -> boolean)

Optional of true if any element matches

empty Optional

allMatch

terminal

allMatch(S -> boolean)

Optional of true if all elements match

empty Optional

noneMatch

terminal

noneMatch(S -> boolean)

Optional of true if no element matches

empty Optional

findFirst

terminal

findFirst()

Optional of the first element

empty Optional

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.

x-ops-filter.jsp: filter, map, flatMap and distinct on the Bookshelf
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
Output
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.

x-ops-order.jsp: sorting with the natural order and with comparators, limit and substream
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()}
Output
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.

  1. A comparator built with subtraction truncates decimals. The comparator result is coerced to an int, so with prices 1.5, 1.2 and 1.0 the differences are 0.3 and 0.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]). Use a.compareTo(b) for decimals; the subtraction form is safe only for integers, as in the by-year line above.

  2. A one-parameter lambda is not a comparator. The specification’s own examples for sorted and limit pass p->p.unitPrice (a key extractor in the style of Java’s Comparator.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 build comparing as 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.

x-ops-side.jsp: forEach, ifPresent, iterator, toArray and toList
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}
Output
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.

x-ops-reduce.jsp: the reductions and the types they return
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()}
Output
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.

x-ops-match.jsp: matching and finding, with the Optional unwrapped
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"}
Output
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.

x-empty.jsp: the same operations on the empty list emptyBooks
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')}
Output
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).

Table 7. The Optional methods of EL 6.0 (section 2.3.3.2)
Method Behaviour On an empty Optional

get()

returns the value

ELException: It is illegal to call get() on an empty optional (observed)

ifPresent(S -> void)

calls the consumer with the value

nothing happens and the call returns null

orElse(other)

returns the value, or other

returns other

orElseGet(() -> other)

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:

x-optional-print.jsp: an Optional printed, and an Optional unwrapped
${books.stream().findFirst()}
${books.stream().filter(b->b.year gt 1970).map(b->b.year).min().get()}
Output (the hash after the at sign differs on every run)
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.

flowchart TD t["Terminal operation returns an Optional"] --> q{"What does the page need?"} q -->|"a value, stream cannot be empty"| g["get()"] q -->|"a value or a default"| oe["orElse(default)
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.

x-maps.jsp: filtering and summing a map literal
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}
Output
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).

x-jstl.jsp (contentType text/html): c:set, c:forEach, c:if and c:out around pipelines
<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()}"/>
Output
<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.

x-report.jsp (contentType text/html): a per-category report with no scriptlet and no helper bean
<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>
Output
<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

Table 8. Lambda, literal and stream pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

ELException: Failed to parse the expression (observed)

x,y->x+y without parentheses, a method reference String::length, a block body

(x,y)->x+y; write s->s.length()

ELException: Only [1] arguments were provided for a lambda expression that requires at least [2] (observed)

a call with fewer arguments than parameters

pass every argument; extra arguments are ignored

ELException: There are more sets of method parameters specified than there are nested lambda expressions (observed)

a second argument list on a lambda that returns a value, not a lambda

curry the lambda (x->y->x+y) or call once

An assigned lambda or value changes a later expression

= in EL creates a page-scope attribute that stays for the rest of the page

unique names, or no assignment; prefer c:set with a clear scope for values

MethodNotFoundException: Method not found: class java.util.HashMap.stream() (observed)

stream() on a Map

entrySet().stream(), values().stream() or keySet().stream()

MethodNotFoundException: Method not found: class org.apache.el.stream.Stream.skip(java.lang.Long) (observed)

an operation that EL does not have: skip, collect, mapToInt, takeWhile, parallel

substream for skip; compute the rest with reduce, sum, count, or in a bean

The page prints org.apache.el.stream.Stream@... (observed)

no terminal operation

end with toList(), toArray(), count() and so on

The page prints org.apache.el.stream.Optional@... (observed)

a terminal operation that returns an Optional (anyMatch, findFirst, max, reduce …​) printed as it is

get(), orElse(...) or orElseGet(...)

ELException: Cannot convert [org.apache.el.stream.Optional@...] ... to [class java.lang.Boolean] (observed)

anyMatch, allMatch or noneMatch used directly in c:if, ?: or and

.orElse(false)

ELException: It is illegal to call get() on an empty optional (observed)

get() after a stream that can be empty

orElse(default) or ifPresent(...)

ELException: Cannot convert [9.5] of type [class java.lang.Double] to [class java.lang.Boolean] (observed)

a filter predicate that returns a number (b->b.price)

return a boolean: b->b.price gt 10

PropertyNotFoundException: Property [nope] not found on type [org.example.bookshelf.Book] (observed)

a typo in a bean or record property inside a lambda

check the component name; records need EL 6.0

ELException caused by ClassCastException: ... cannot be cast to class java.lang.Comparable (observed)

sorted() or max() without a comparator on elements that are not Comparable (a Book)

pass a comparator, or map to a comparable key first

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 stream() again for each pipeline

sorted((a,b)->a-b) leaves decimals unsorted (observed)

the difference is coerced to an int and truncated to 0

a.compareTo(b)

sorted(p->p.price) returns an arbitrary order (observed)

a key extractor is not a comparator; the specification’s own examples contain this error

a two-parameter comparator

ClassCastException: class java.lang.Long cannot be cast to class java.lang.Integer (observed)

a lambda coerced to a Java Comparator returned a Long from a-b

a.compareTo(b)

UnsupportedOperationException (observed)

a mutating method (sort, removeIf, add) called on an immutable list from the controller

never mutate model data in a view; sort with stream().sorted(...).toList()

ELException caused by NullPointerException (observed)

a null element reached sorted(), or a null argument such as limit(null)

filter(x->x != null) first; pass a number

Unexpected values, no error (observed)

null elements go through map (it returns [1, null, 1] for [a, null, b]); flatMap of a non-stream returns an empty list

filter nulls; make the mapper return x.stream()

A user’s text is shown unescaped

EL does not escape; a pipeline result is printed as it is

c:out or fn:escapeXml on every value that can contain text from users

An expression built from request data (${param.expr} evaluated through an ELProcessor or ExpressionFactory)

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

Table 9. Changes that touch lambdas, literals and streams
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 javax.el to jakarta.el; a lambda written for EL 3.0 still works after the package rename

EL 5.0 (Jakarta EE 10)

a LambdaExpression can be coerced to a functional interface method invocation (the spec’s change item #45, section 1.25.8), so a lambda can be passed where a Predicate or Comparator is expected; array coercion; BeanELResolver considers default methods; generics throughout the API; minimum Java 11

EL 6.0 (Jakarta EE 11)

minimum Java 17; the java.beans dependency becomes optional; length property for arrays (so words.stream().toArray().length works); RecordELResolver enabled by default (so a record is a bean inside a lambda); opt-in OptionalELResolver; SecurityManager references removed. The stream operations and lambda syntax are unchanged

EL 6.1 (Jakarta EE 12, under development)

the plan on the specification page lists an Elvis operator and + to merge collections among the language enhancements, plus pluggable caching and a performance optimisation for scoped attributes in cooperation with Pages 4.1; no removals planned; minimum Java 17. These are plans, not released behaviour; Tomcat 12 is not released

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.

Table 10. Material from the books and its current equivalent
The books' era Today (EL 6.0, Pages 4.0, JSTL 3.0)

Filtering a collection by writing a c:forEach with a c:if inside and a counter

a filter and a count, or toList() fed to a single c:forEach

Sorting with a scriptlet and java.util.Collections.sort with a Comparator class, or in the servlet

sorted((p,q)->...) in the view; or still in the servlet for anything beyond display order

Summing or averaging in a scriptlet loop or a custom tag

sum(), average(), max() and min()

A tag or a scriptlet to build a list or a map of constants

[...] and {...} literals, also as items of c:forEach

Calling a bean method with a value computed by Java

a lambda argument (LambdaExpression or a functional interface)

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

java.sun.com/jstl/core_rt and the _rt libraries so that Java expressions could be used in attributes

not needed: EL works in every attribute; the JSTL 3.0 URIs are jakarta.tags.*

javax.el.* classes in code that customised EL

jakarta.el.* since EL 4.0

Treating EL as read-only (no side effects)

assignment (=), sequence (;) and mutating method calls are legal; the discipline that views do not change the model is a convention, not a rule of the language

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 Optional); its security implications are in that page

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