EL Static Access, Records & Optional
|
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 (jakarta.el:jakarta.el-api 6.0.1), Jakarta Pages 4.0, Jakarta
Standard Tag Library 3.0 and Jakarta Servlet 6.1, on Java 21 and Tomcat 11.0.26, the container whose Jasper and EL
implementation ran every example. Everything was verified on 2026-10-08: the facts against the EL 6.0 and Pages 4.0
specifications and the EL Javadoc, and every example by building it into a Maven WAR and requesting it with curl; the
outputs and error messages shown are the ones that run produced. Besides properties of beans, maps and lists, EL can
reach four more kinds of thing: the static members of a class (${Integer.MAX_VALUE}, ${Math.max(3, 7)}), the
constants of an enum, the length of an array, and the components of a Java record, and it can see through a
java.util.Optional if the page or application asks for it. Three of these are ordinary parts of every page, one is
off unless you switch it on, and each has an access rule that surprises people the first time it silently prints
nothing. The running scenario is the Bookshelf, a small catalogue of books; the same Book is a record with an enum
status and an optional ISBN.
The Bookshelf Used on This Page
The examples below are views of one WAR, deployed as g4elstatic.war under the context path /g4elstatic. A servlet
(ViewServlet, mapped to /view/*) puts a Book, a second Book without an ISBN, a list of both, an array of their
titles and an Optional<Book> into the request and forwards to /WEB-INF/views/<name>.jsp for a fixed list of view
names. The model is a record Book with an Author record and a Status enum, plus a final class Shelf that holds
constants and static helpers. The scenario classes are shown once here; every other listing on the page is a JSP or a
descriptor.
public enum Status {
AVAILABLE("On the shelf"), LOANED("Out on loan"), LOST("Lost");
private final String label;
Status(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
public record Author(String name, String country) {
}
public record Book(long id, String title, Author author, int year, Status status, Optional<String> isbn) {
public boolean isRecent() {
return year >= 2020;
}
public String shortTitle() {
return title.length() > 12 ? title.substring(0, 12) + "..." : title;
}
}
public final class Shelf {
public static final int MAX_LOANS = 3;
public static final String NAME = "Main shelf";
public static int counter = 0;
static final int HIDDEN = 7;
private static final int SECRET = 9;
private Shelf() {
}
public static String describe(Book book) {
return book.title() + " (" + book.year() + ")";
}
public static boolean canLoan(int current) {
return current < MAX_LOANS;
}
}
@WebServlet("/view/*")
public class ViewServlet extends HttpServlet {
private static final Set<String> VIEWS = Set.of("static", "enum", "record", "optional-basic", "m-ispresent", "m-orelse-empty", "m-orelse-present", "m-unwrap", "err-optional-get", "optional-nav", "optional-el", "array", "imports",
"imports2", "imports4", "imports3", "err-array", "err-record-write", "err-record-hidden", "inc-main", "err-param", "err-assign", "err-private", "err-enum", "err-record", "err-nopub", "ctor");
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String name = req.getPathInfo() == null ? "" : req.getPathInfo().substring(1);
if (!VIEWS.contains(name)) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Author author = new Author("Ursula K. Le Guin", "US");
Book book = new Book(1L, "The Dispossessed", author, 1974, Status.AVAILABLE, Optional.of("978-0-06-051275-0"));
Book noIsbn = new Book(2L, "Zine Issue 7", new Author("Anon", "ES"), 2023, Status.LOANED, Optional.empty());
req.setAttribute("book", book);
req.setAttribute("noIsbn", noIsbn);
req.setAttribute("books", List.of(book, noIsbn));
req.setAttribute("titles", new String[] {book.title(), noIsbn.title()});
req.setAttribute("hidden", new Hidden("secret-free")); req.setAttribute("status", Status.LOANED);
req.setAttribute("rawOptional", Optional.of(book));
req.getRequestDispatcher("/WEB-INF/views/" + name + ".jsp").forward(req, resp);
}
}
The two Book instances, the list, the array and the Optional<Book> are ordinary request attributes, so every JSP
reads them as ${book}, ${noIsbn}, ${books}, ${titles} and ${rawOptional}. A second copy of the WAR,
g4elstaticopt.war, differs from the first in one context parameter only; that is the Optional switch, explained in the
Optional section.
Where These Features Sit in the Resolver Chain
Every EL expression in a page is evaluated through one jakarta.el.CompositeELResolver that the container builds. Pages 4.0
section 2.10 lists its components in order (the Implicit Objects and Scopes page shows the same chain from the scope side), and the three features of this page are three of the entries. The
expression ${Shelf.MAX_LOANS} is resolved by the import resolver (to a class), then by StaticFieldELResolver (to
the field); ${book.title} is resolved by RecordELResolver before the generic BeanELResolver ever sees it; and
${book.isbn.value} can only work if somebody added OptionalELResolver to the part of the chain that is reserved for
application resolvers.
| Feature | Implemented by | Specification | On by default in a page? |
|---|---|---|---|
Static fields, static methods, enum constants and constructor calls |
|
EL 1.21, 1.24; Pages 2.10 |
yes, for |
Array |
|
EL 1.22 |
yes (EL 6.0) |
Records |
|
EL 6.0 changes #201; Pages 2.10 |
yes (EL 6.0), listed in the Pages 4.0 chain just before |
|
|
EL 1.23; EL 6.0 changes #176 |
no: an application must register it |
The Pages 4.0 text calls the static resolver StaticFieldResolver; the class in the EL API is StaticFieldELResolver.
How ELResolver works, what getValue and propertyResolved mean, and how to write a resolver of your own are on the
Expression Language Configuration page of this section.
Static Fields and Methods
The syntax is classname.member, where classname is the simple class name without its package: ${Boolean.TRUE},
${Integer.MAX_VALUE}, ${Math.max(3, 7)}. The specification attaches four security restrictions: only public
static fields and methods can be referenced, they must be in an accessible (exported) module, static fields cannot be
modified, and, except for java.lang, a class has to be imported before its static members can be named.
| Rule | What happens when it is broken | Observed message or result |
|---|---|---|
The member must be |
|
|
The class must be in an exported package (EL 1.24.1, "accessible module") |
the member is not found |
not reproduced separately; the message of the previous row says "on exported class" |
A static field is read-only |
|
|
The class must be imported (except |
the class name resolves to nothing, so the whole expression is |
no exception, no output |
An imported class must be public, concrete and not an interface |
|
|
Reading Static Fields and Calling Static Methods
The view below uses the java.lang classes Boolean, Integer and Math with no import, and the Bookshelf class
Shelf after a page directive import. Static methods take arguments in the normal way, and they can take beans from
the page: ${Shelf.describe(book)} passes the Book request attribute to a static method.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ page import="org.example.bookshelf.Shelf" %>
Boolean.TRUE = ${Boolean.TRUE}
Integer.MAX_VALUE = ${Integer.MAX_VALUE}
Math.max(3, 7) = ${Math.max(3, 7)}
Integer.parseInt('42') + 1 = ${Integer.parseInt('42') + 1}
Shelf.MAX_LOANS = ${Shelf.MAX_LOANS}
Shelf.NAME = ${Shelf.NAME}
Shelf.describe(book) = ${Shelf.describe(book)}
Shelf.canLoan(2) = ${Shelf.canLoan(2)}
Shelf.canLoan(3) = ${Shelf.canLoan(3)}
curl http://localhost:18080/g4elstatic/view/staticBoolean.TRUE = true
Integer.MAX_VALUE = 2147483647
Math.max(3, 7) = 7
Integer.parseInt('42') + 1 = 43
Shelf.MAX_LOANS = 3
Shelf.NAME = Main shelf
Shelf.describe(book) = The Dispossessed (1974)
Shelf.canLoan(2) = true
Shelf.canLoan(3) = false
Default Imports and the page Directive
The EL on its own imports java.lang.* only. The specification lets a technology that uses EL add more, and Pages does:
the import attribute of the page directive (see the Directives page) describes "the types that are available to the scripting and EL
environments", and the container implicitly imports java.lang.*, jakarta.servlet.*, jakarta.servlet.jsp.* and
jakarta.servlet.http.*. That the EL environment has the same defaults as the scripting environment was clarified in
Pages 3.1. A page can add more with import, as it does for Shelf above, and can import a whole package or a single
class.
| Source | Effect |
|---|---|
|
always imported: |
|
implicitly imported by the container (Pages 4.0 1.10.1); concrete classes only, see the pitfall below |
|
adds a class or a package for the scripting and the EL environment of that page |
|
adds a class at run time, from an |
|
adds a package at run time |
|
lets a static field or method be named without its class: |
|
the specification’s way to have extra imports "transparently" in many pages (see the Configuration and Property Groups page) |
The import handler is the object ELContext.getImportHandler() returns, and PageContext.getELContext() gives a page its
ELContext. A page without Java code cannot reach that object, so the place to add imports at run time is an
ELContextListener registered with JspApplicationContext.addELContextListener: the container calls its contextCreated
method for every ELContext it creates for a page, and the listener adds its imports through the ImportHandler of the
new context. The listener is registered once, from a ServletContextListener, and applies to every page of the application:
@WebListener
public class ElImportsListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
JspFactory.getDefaultFactory()
.getJspApplicationContext(event.getServletContext())
.addELContextListener(ece -> {
ImportHandler imports = ece.getELContext().getImportHandler();
imports.importStatic("java.lang.Math.max");
imports.importStatic("org.example.bookshelf.Shelf.MAX_LOANS");
});
}
}
The next view shows the three styles in one page: a package import (java.time.*), a class import
(java.math.RoundingMode, org.example.bookshelf.Status) and the two static imports made by the listener.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ page import="java.math.RoundingMode, java.time.*" %>
<%@ page import="org.example.bookshelf.Status" %>
RoundingMode.FLOOR = ${RoundingMode.FLOOR}
LocalDate.of(2026, 10, 8).year = ${LocalDate.of(2026, 10, 8).year}
Duration.ofMinutes(90).toHours() = ${Duration.ofMinutes(90).toHours()}
max(3, 7) = ${max(3, 7)}
MAX_LOANS = ${MAX_LOANS}
Status.AVAILABLE = ${Status.AVAILABLE}
curl http://localhost:18080/g4elstatic/view/importsRoundingMode.FLOOR = FLOOR
LocalDate.of(2026, 10, 8).year = 2026
Duration.ofMinutes(90).toHours() = 1
max(3, 7) = 7
MAX_LOANS = 3
Status.AVAILABLE = AVAILABLE
Legacy alternative: older pages made the same static imports for one page only, from a scriptlet at the top of the page,
pageContext.getELContext().getImportHandler().importStatic("java.lang.Math.max"). It gives the same output but puts Java
code in the view, which the rest of this section avoids; prefer the listener, or a tag handler when the imports belong to
one page.
Two things in that output are worth stating. A static import (importStatic) of a static method makes the name usable without its class, which is why
${max(3, 7)} works with no class. And the page-level import is per page: the import handler belongs to the page’s ELContext, so a
class imported in one page is not known to a page that it includes dynamically. The include directive is different,
because it merges the source of the included file into the including page before translation, so the imports apply
to it. The next view includes the same fragment both ways.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ page import="org.example.bookshelf.Shelf" %>
main page : [${Shelf.MAX_LOANS}]
<jsp:include page="/WEB-INF/views/inc-part.jsp"/>
<%@ include file="/WEB-INF/views/inc-part.jsp" %>
part (included) : [${Shelf.MAX_LOANS}]
curl http://localhost:18080/g4elstatic/view/inc-mainmain page : [3]
part (included) : []
part (included) : [3]
The jsp:include copy runs as a separate page, so Shelf is unknown there and prints nothing; the include directive copy
sees the import. The Includes, Forwards and Page Composition page
treats the two mechanisms in full.
Constructor References
A class name followed by arguments in parentheses calls the constructor, under the same rules as a static method: the
class must be public, concrete and already imported. ${Boolean(true)} is the specification’s own example.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ page import="java.util.Locale, java.math.BigDecimal" %>
Boolean(true) = ${Boolean(true)}
BigDecimal('19.90').add(BigDecimal('0.10')) = ${BigDecimal('19.90').add(BigDecimal('0.10'))}
Locale('es').getDisplayLanguage(Locale.ENGLISH) = ${Locale('es').getDisplayLanguage(Locale.ENGLISH)}
curl http://localhost:18080/g4elstatic/view/ctorBoolean(true) = true
BigDecimal('19.90').add(BigDecimal('0.10')) = 20.00
Locale('es').getDisplayLanguage(Locale.ENGLISH) = Spanish
What java.lang Gives a Page, and Why That Matters
The default imports are a convenience for page authors and a hazard for everything else. java.lang contains Runtime,
System, Thread and ProcessBuilder, and the Pages packages contain Cookie, so an expression that an attacker can
choose is not limited to reading the Bookshelf. The next page prints only harmless values, which is the point: the
reach is there, and it is the reason this section never builds an expression from request input.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
Cookie('a','b').name = [${Cookie('a', 'b').name}]
PageContext.REQUEST_SCOPE = [${PageContext.REQUEST_SCOPE}]
HttpServletResponse.SC_NOT_FOUND = [${HttpServletResponse.SC_NOT_FOUND}]
HttpServletResponse.SC_NOT_FOUND via full name = [${jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND}]
HttpServletRequest.BASIC_AUTH = [${HttpServletRequest.BASIC_AUTH}]
Runtime.getRuntime().availableProcessors() > 0 = ${Runtime.getRuntime().availableProcessors() gt 0}
System.getProperty('java.specification.version') = ${System.getProperty('java.specification.version')}
curl http://localhost:18080/g4elstatic/view/imports2Cookie('a','b').name = [a]
PageContext.REQUEST_SCOPE = []
HttpServletResponse.SC_NOT_FOUND = []
HttpServletResponse.SC_NOT_FOUND via full name = []
HttpServletRequest.BASIC_AUTH = []
Runtime.getRuntime().availableProcessors() > 0 = true
System.getProperty('java.specification.version') = 21
Three rules follow from the output.
-
Concrete classes only.
Cookieis a class, so its constructor and.namework.HttpServletResponseandHttpServletRequestare interfaces andPageContextis an abstract class, so${HttpServletResponse.SC_NOT_FOUND}and${PageContext.REQUEST_SCOPE}print an empty string even though the packages are on the default import list. Naming such a class in apageimport, or inimportStatic, does not help: it raises theELExceptionquoted in the table above. To useHttpServletResponse.SC_NOT_FOUNDin a page, put the number in a constant of your own class or write the literal. -
Never the package-qualified name. The qualified name is not parsed as a class:
jakartais looked up as an attribute, found nowhere, and the whole expression isnull. -
Empty is not an error. An unimported class and an interface constant both evaluate to
null, becauseNotFoundELResolveralways resolves, and anullprints as nothing. TheerrorOnELNotFoundsetting changes that for a bare identifier in development; see the Expression Language Configuration page of this section.
The Failures
Each of the three pages below fails or prints nothing on purpose; every message is quoted from the response.
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ page import="org.example.bookshelf.Shelf" %>
before = ${Shelf.counter}
assign = ${Shelf.counter = 5}
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ page import="org.example.bookshelf.Shelf" %>
secret = ${Shelf.SECRET}
<%@ page contentType="text/plain;charset=UTF-8" %>
Shelf (no import) = [${Shelf.MAX_LOANS}]
Status (no import) = [${Status.AVAILABLE}]
book.status == Status.AVAILABLE (no import) = [${book.status == Status.AVAILABLE}]
| Request | Result |
|---|---|
|
HTTP 500: |
|
HTTP 500: |
|
HTTP 200 with three empty values ( |
The third row is the dangerous one: the comparison ${book.status == Status.AVAILABLE} is quietly false for every
book when the Status import is missing, because Status is null. A page that shows a "Borrow" button only when the
status equals the constant would hide it for everybody, with no log line.
Enums
An enum constant is a public static field, so ${RoundingMode.FLOOR} and ${Status.AVAILABLE} use the static syntax
above and need the same import. The specification adds a second route for values that are already enum instances: an
enum is compared with a string, and the string is coerced to the enum type with Enum.valueOf, so ${mySuit == 'Spade'}
is true when mySuit is the Spade constant. Everything else about an enum is ordinary Java: its getters are
properties (${status.label} calls getLabel()), its methods are callable (name(), ordinal()), and its static
methods values() and valueOf(String) are static methods of the imported enum class.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ page import="org.example.bookshelf.Status" %>
book.status = ${book.status}
book.status == 'AVAILABLE' = ${book.status == 'AVAILABLE'}
book.status == Status.AVAILABLE = ${book.status == Status.AVAILABLE}
status eq Status.LOANED = ${status eq Status.LOANED}
status.label = ${status.label}
status.name() = ${status.name()}
status.ordinal() = ${status.ordinal()}
Status.valueOf('LOST').label = ${Status.valueOf('LOST').label}
Status.values().length = ${Status.values().length}
Status.values()[1] = ${Status.values()[1]}
fn:length(Status.values()) shown via stream = ${Status.values().stream().map(s -> s.label).toList()}
curl http://localhost:18080/g4elstatic/view/enumbook.status = AVAILABLE
book.status == 'AVAILABLE' = true
book.status == Status.AVAILABLE = true
status eq Status.LOANED = true
status.label = Out on loan
status.name() = LOANED
status.ordinal() = 1
Status.valueOf('LOST').label = Lost
Status.values().length = 3
Status.values()[1] = LOANED
fn:length(Status.values()) shown via stream = [On the shelf, Out on loan, Lost]
The string route is case-sensitive and strict. A value that is not a constant name does not compare unequal; it fails:
<%@ page contentType="text/plain;charset=UTF-8" %>
book.status == 'available' = ${book.status == 'available'}
curl http://localhost:18080/g4elstatic/view/err-enum returns HTTP 500 with jakarta.el.ELException: Cannot convert [available] of type [class java.lang.String] to [class org.example.bookshelf.Status]. Prefer the
constant (${book.status == Status.AVAILABLE}, with the import) or the name method
(${book.status.name() eq 'AVAILABLE'}), which compare without a coercion that can throw. A coercion error also
appears when a request parameter is compared with an enum: ${param.status == Status.LOST} throws for any status the
user mistypes, so convert input in the servlet or the controller, not in the page.
Array length
EL treats length on a Java array as a property. Before EL 6.0 the portable way to ask for the size of an array was
${fn:length(titles)} from the JSTL functions library, which also works on collections, maps and strings; both are
shown here. The special case is in ArrayELResolver and applies only to real Java arrays: a List has size(), not a
length property.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
titles.length = ${titles.length}
titles[0] = ${titles[0]}
fn:length(titles) = ${fn:length(titles)}
books.size() = ${books.size()}
curl http://localhost:18080/g4elstatic/view/arraytitles.length = 2
titles[0] = The Dispossessed
fn:length(titles) = 2
books.size() = 2
Asking a List for ${books.length} is the usual mistake: ListELResolver treats the property as an index and the
request fails with java.lang.NumberFormatException: For input string: "length" (err-array.jsp).
Records
A Java record has no getters. Its accessors are methods named after its components (title(), not getTitle()),
so a resolver that only looks for getX and isX, which is what BeanELResolver does, cannot read it. EL 6.0 adds
RecordELResolver for exactly this case. It handles any base object that is a java.lang.Record, accepts any non-null
property, coerces it to a string with toString() and uses that string to find the accessor method of the component
with the same name. It is always read-only. Because EL 6.0 enables it by default and Pages 4.0 lists it right before
BeanELResolver, a record works as a bean on Tomcat 11 with no configuration: ${book.title} calls title().
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
book.title = ${book.title}
book['year'] = ${book['year']}
book.author.name = ${book.author.name}
book.author.country == 'US' = ${book.author.country == 'US'}
book.title() (method call) = ${book.title()}
book.shortTitle() = ${book.shortTitle()}
books.stream().map(b -> b.title).toList() = ${books.stream().map(b -> b.title).toList()}
book.equals(book) = ${book.equals(book)}
book.toString() = ${book.toString()}
curl http://localhost:18080/g4elstatic/view/recordbook.title = The Dispossessed
book['year'] = 1974
book.author.name = Ursula K. Le Guin
book.author.country == 'US' = true
book.title() (method call) = The Dispossessed
book.shortTitle() = The Disposse...
books.stream().map(b -> b.title).toList() = [The Dispossessed, Zine Issue 7]
book.equals(book) = true
book.toString() = Book[id=1, title=The Dispossessed, author=Author[name=Ursula K. Le Guin, country=US], year=1974, status=AVAILABLE, isbn=Optional[978-0-06-051275-0]]
Nested records chain as usual (${book.author.name}), the bracket form works (${book['year']}), a lambda over a list of
records reads components with the same syntax, and the accessor can also be called as a method (${book.title()}),
which is the normal EL method-call rule and not something the record resolver adds. A record is also an ordinary Java
class, so toString(), equals and any method you add (shortTitle()) are callable; the toString() output shows the
Optional component in its Java form, which matters in the next section.
Record Rules and Failures
Because the record resolver looks only for the component accessor, every property is a component name, and nothing else counts. The three failures below were reproduced, each on its own page.
<%@ page contentType="text/plain;charset=UTF-8" %>
book.recent = ${book.recent}
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set target="${book}" property="title" value="Changed"/>
<%@ page contentType="text/plain;charset=UTF-8" %>
hidden.name = ${hidden.name}
| Request | Result |
|---|---|
|
HTTP 500: |
|
HTTP 500: |
|
HTTP 500: |
Records also fit the rest of the platform: they are a good shape for a read-only view model that a servlet builds and a JSP prints, and the Java side of the language feature is on the Records and Sealed Classes page, with Enums for the enum side. Form binding is the opposite case: a record has no setters, so a framework that binds request parameters by calling setters on a bean (see Forms, Validation and Sessions, Spring MVC with JSP and the Struts parameter injection page) needs a mutable class or a framework that constructs records.
java.util.Optional
There are two different Optional classes in EL and the page must not mix them up.
| Class | Where it comes from | What EL does with it |
|---|---|---|
|
a property or a method result in your model, such as |
by default nothing special: an ordinary object, whose methods ( |
The EL stream |
the result of the terminal stream operations |
always has |
The first row is the subject of this section; the second is covered by the Lambdas, Collections and Streams page, and a short example of it comes at the end.
Default Behaviour: Optional Is Just an Object
Without any registration, a java.util.Optional is printed with its toString(), is never null, is never empty, and
exposes only its own methods. This is the output of the plain WAR (g4elstatic, no resolver):
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
book.isbn = [${book.isbn}]
noIsbn.isbn = [${noIsbn.isbn}]
c:out book.isbn = [<c:out value="${book.isbn}"/>]
c:out noIsbn.isbn default = [<c:out value="${noIsbn.isbn}" default="n/a"/>]
empty book.isbn = ${empty book.isbn}
empty noIsbn.isbn = ${empty noIsbn.isbn}
noIsbn.isbn == null = ${noIsbn.isbn == null}
book.isbn == '978-0-06-051275-0' = ${book.isbn == '978-0-06-051275-0'}
noIsbn.isbn ne null? c:if = <c:if test="${not empty noIsbn.isbn}">has isbn</c:if><c:if test="${empty noIsbn.isbn}">no isbn</c:if>
curl http://localhost:18080/g4elstatic/view/optional-basic (no OptionalELResolver)book.isbn = [Optional[978-0-06-051275-0]]
noIsbn.isbn = [Optional.empty]
c:out book.isbn = [Optional[978-0-06-051275-0]]
c:out noIsbn.isbn default = [Optional.empty]
empty book.isbn = false
empty noIsbn.isbn = false
noIsbn.isbn == null = false
book.isbn == '978-0-06-051275-0' = false
noIsbn.isbn ne null? c:if = has isbn
Three lines deserve a second look. The empty ISBN is printed as Optional.empty and not as nothing. ${empty noIsbn.isbn}
is false, and c:if with not empty therefore takes the "has isbn" branch for a book with no ISBN. To test for
presence without the resolver, call the method: ${noIsbn.isbn.isPresent()}, shown next.
Navigating through the Optional does not work. The rawOptional attribute is an Optional<Book>:
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
rawOptional.title = ${rawOptional.title}
rawOptional.author.name = ${rawOptional.author.name}
rawOptional.shortTitle() = ${rawOptional.shortTitle()}
curl http://localhost:18080/g4elstatic/view/optional-nav returns HTTP 500 with jakarta.el.PropertyNotFoundException: Property [title] not found on type [java.util.Optional], which is
exactly the failure the specification describes for someObject.optionalProperty.otherProperty.
The methods of the Optional itself are called like any other method:
<%@ page contentType="text/plain;charset=UTF-8" %>
book.isbn.isPresent() = [${book.isbn.isPresent()}]
<%@ page contentType="text/plain;charset=UTF-8" %>
noIsbn.isbn.orElse('n/a') = [${noIsbn.isbn.orElse('n/a')}]
<%@ page contentType="text/plain;charset=UTF-8" %>
book.isbn.orElse('n/a') = [${book.isbn.orElse('n/a')}]
On the plain WAR these three return [true], [n/a] and [978-0-06-051275-0].
Switching the Resolver On
OptionalELResolver is a normal ELResolver, so it is registered the way any application resolver is: through the
JspApplicationContext of the web application, which Pages 4.0 section 2.10 places right after the implicit objects in
the chain. The natural place is a ServletContextListener; the one below reads a context parameter so the same code ran
as the plain WAR (false) and the Optional-enabled WAR (true).
@WebListener
public class OptionalSupport implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
ServletContext context = event.getServletContext();
if (Boolean.parseBoolean(context.getInitParameter("optionalEL"))) {
JspFactory.getDefaultFactory().getJspApplicationContext(context).addELResolver(new OptionalELResolver());
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
version="6.1">
<context-param>
<param-name>optionalEL</param-name>
<param-value>false</param-value>
</context-param>
</web-app>
The registration is application-wide and happens once, at start-up. After it the chain looks through every
java.util.Optional in every page of the application: this is a global change of semantics that cannot be limited to one page, so every page that calls .isPresent() or .orElse(...) breaks (below). The Javadoc defines the
behaviour precisely: for an Optional base, an empty Optional resolves every property to null; a populated one
resolves the property on its content, by calling the whole resolver chain with the content as the base; with a null
property the content itself is the value; method calls are forwarded to the content, and an empty Optional answers
null to every method call; and coercion to a type is done on the content, or on null when empty.
| Expression | Plain WAR (no resolver) | Optional-enabled WAR |
|---|---|---|
|
|
|
|
|
empty string (coerced from |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
empty string: the call goes to the content, and an empty Optional answers |
|
the ISBN |
|
|
|
|
The Optional-enabled outputs, side by side with the plain ones above:
curl http://localhost:18080/g4elstaticopt/view/optional-basic and /optional-nav (resolver registered)book.isbn = [978-0-06-051275-0]
noIsbn.isbn = []
c:out book.isbn = [978-0-06-051275-0]
c:out noIsbn.isbn default = [n/a]
empty book.isbn = false
empty noIsbn.isbn = false
noIsbn.isbn == null = false
book.isbn == '978-0-06-051275-0' = true
noIsbn.isbn ne null? c:if = has isbn
rawOptional.title = The Dispossessed
rawOptional.author.name = Ursula K. Le Guin
rawOptional.shortTitle() = The Disposse...
Three of the rows deserve a sentence each. The empty operator is not touched by the resolver: an Optional is never
empty and never null as far as empty and == against null are concerned, even though it prints as nothing and
compares equal to its content. A c:if on not empty book.isbn is therefore true for every book. The orElse row is a
silent change of meaning: the same expression returns the default on the plain WAR and an empty string on the
Optional-enabled one, with no error. And the two method rows show that with the resolver, the Optional’s own API is
unreachable.
What to Do With Optional in a Page
The pattern that works with and without the resolver, and that does not tie the pages to a global switch, is to unwrap in
the servlet or in the view model. Put the nullable value in the request (book.isbn().orElse(null)), or add a component
of a plain type to the view record, and let the page use the ordinary EL null handling, empty, c:if and c:out
default. Reach for the resolver only in an application that has chosen to model its optional properties as Optional
and to read them with property syntax everywhere, so that no page calls an Optional method. In that case register it
once, in a listener, and never mix the two styles, as the table above shows why.
The Other Optional: Results of EL Streams
A terminal operation on an EL stream returns EL’s own Optional, with four methods: get() (an ELException if
empty), ifPresent, orElse and orElseGet. It is independent of the resolver: the same pages behave the same on both
WARs.
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
books.stream().findFirst().get().title = ${books.stream().findFirst().get().title}
books.stream().map(b -> b.title).findFirst().orElse('none') = ${books.stream().map(b -> b.title).findFirst().orElse('none')}
books.stream().filter(b -> b.year > 2100).findFirst().orElse('none') = ${books.stream().filter(b -> b.year > 2100).findFirst().orElse('none')}
books.stream().filter(b -> b.year > 2100).findFirst().orElseGet(() -> 'none') = ${books.stream().filter(b -> b.year > 2100).findFirst().orElseGet(() -> 'none')}
books.stream().map(b -> b.title).findFirst().ifPresent(t -> t) = [${books.stream().map(b -> b.title).findFirst().ifPresent(t -> t)}]
curl http://localhost:18080/g4elstatic/view/optional-el (identical on the Optional-enabled WAR)books.stream().findFirst().get().title = The Dispossessed
books.stream().map(b -> b.title).findFirst().orElse('none') = The Dispossessed
books.stream().filter(b -> b.year > 2100).findFirst().orElse('none') = none
books.stream().filter(b -> b.year > 2100).findFirst().orElseGet(() -> 'none') = none
books.stream().map(b -> b.title).findFirst().ifPresent(t -> t) = []
Calling get() on an empty one fails (err-optional-get.jsp, the expression ${books.stream().filter(b -> b.year > 2100).findFirst().get()}): HTTP 500 with jakarta.el.ELException: It is illegal to call get() on an empty optional. There is no isPresent() on the EL class, so the test for emptiness is orElse with a default, or orElseGet. The lambdas and the stream
operations themselves are on the Lambdas, Collections and Streams page; the Java language side is on the
Optional, Lambdas
and Method References and Streams and Collectors pages.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
An expression with a class name prints nothing (observed) |
the class is not imported, so the name is an unknown identifier and |
add |
|
interfaces and abstract classes are skipped by the import handler, even on the default package list |
a constant in your own concrete class, or the literal |
|
an explicit |
remove the import; use a concrete class |
|
the field is package-private, private or missing, or the class is in a non-exported package |
make it |
|
an assignment ( |
change it in Java, not in EL |
|
|
import the enum; turn on |
|
a string that is not a constant name compared with an enum |
compare with the constant, or |
|
|
|
|
the property is not a record component ( |
|
|
the record type is not public |
declare the record |
|
|
build a new record in the servlet |
|
navigating through an |
unwrap in the servlet, or register the resolver |
|
the resolver is registered and the page still calls the Optional API |
drop the call, or remove the resolver |
|
|
|
|
|
|
Request input becomes code |
a servlet or tag builds an EL string from a parameter and evaluates it with |
never; input is data, and the default imports reach |
What Changed Recently
| Change | Detail |
|---|---|
EL 6.0 (Jakarta EE 11) |
Java 17 is the minimum; deprecated code is removed and the dependency on the |
EL 5.0 (Jakarta EE 10) |
|
Pages 4.0 (Jakarta EE 11) |
minimum Java 17; the |
Pages 3.1 (Jakarta EE 10) |
it is clarified that the EL environment of a page has the same default imports as the scripting environment, and the special handling of imports leaves |
EL 6.1, Pages 4.1 (milestones) |
milestone releases of both specifications are published ( |
Where the Books Are Outdated
The books were written for JSP 1.2 and JSTL 1.0, before the Expression Language existed as a language (JSP 2.0) and long before it could name classes (EL 3.0). What they teach about how a page reaches a constant, a helper or an optional value is therefore built from the pieces they had, and the pieces have since been replaced.
| The books' era | Today (EL 6.0, Pages 4.0, Servlet 6.1) |
|---|---|
Constants and helper methods reached through |
|
EL functions declared in a TLD |
still valid for tag libraries; for your own helpers a static call needs no TLD, and the specification’s own text notes that functions without a prefix can no longer be validated at translation time because of imports |
Enum values compared as strings, or an |
|
JSTL 1.0 |
plain EL in the attribute; JSTL 3.0 has one library per area ( |
A bean is a class with getters and setters; no records (Java 14 and later) |
records work with |
The size of an array through a scriptlet, |
|
No |
|
|
|
Struts 1 and Struts 2 pages that read static values through the Struts tag libraries or OGNL ( |
OGNL static access is restricted in current Struts releases; the Struts Security Hardening and ValueStack, ActionContext and OGNL pages cover it with the other OGNL security settings. In a JSP, EL with a |
Source: Manning — JSTL in Action
References
-
Jakarta Pages 4.0 — 2.10 Resolution of Variables and their Properties
-
Jakarta Expression Language 6.0 — A.1 Changes between 6.0 and 5.0
-
Jakarta Expression Language 6.0 — 1.24.1 Access Restrictions and Imports
-
Jakarta Expression Language 6.0 Javadoc — StaticFieldELResolver
-
Jakarta Expression Language 6.0 — 1.24 Static Field and Method Reference
-
Jakarta Expression Language 6.0 — 1.24.2 Imports of Packages, Classes, and Static Fields
-
Jakarta Pages 4.0 Javadoc — JspApplicationContext.addELContextListener
-
Jakarta Expression Language 6.0 — 1.24.4 Additional Default Imports
-
Jakarta Expression Language 6.0 — 1.24.3 Constructor Reference
-
Jakarta Expression Language 6.0 — 1.25.6 Coerce A to an Enum Type T
-
Jakarta Pages 4.0 Javadoc — JspApplicationContext.addELResolver
-
Jakarta Pages 4.0 Javadoc — JspFactory.getJspApplicationContext
-
Jakarta Expression Language 6.0 Javadoc — OptionalELResolver
-
Jakarta Expression Language 6.0 Javadoc — OptionalELResolver.getValue
-
Jakarta Expression Language 6.0 Javadoc — OptionalELResolver.invoke
-
Jakarta Expression Language 6.0 — A.2 Changes between 5.0 and 4.0
-
Manning — Web Development with JavaServer Pages, Second Edition