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.

Status.java and Author.java
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) {
}
Book.java: a record with an enum component, an Optional component and two extra methods
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;
    }
}
Shelf.java: the static members the pages read (one is package-private and one is private on purpose)
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;
    }
}
ViewServlet.java (imports omitted)
@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.

flowchart LR subgraph withBase["Base is not null (the object before the dot)"] direction LR a1["ImplicitObjectELResolver"] --> a2["Resolvers added with JspApplicationContext.addELResolver"] a2 --> a3["Stream resolver"] --> a4["StaticFieldELResolver"] --> a5["MapELResolver"] a5 --> a6["ResourceBundleELResolver"] --> a7["ListELResolver"] --> a8["ArrayELResolver (length)"] a8 --> a9["RecordELResolver (read only)"] --> a10["BeanELResolver"] end subgraph noBase["Base is null (a bare name)"] direction LR b1["ScopedAttributeELResolver"] --> b2["ImportELResolver"] --> b3["NotFoundELResolver (returns null)"] end opt["OptionalELResolver: opt-in, registered here"] -.-> a2 style opt stroke-dasharray: 5 5 style a4 stroke-width:3px style a8 stroke-width:3px style a9 stroke-width:3px
Table 1. Where each feature is implemented
Feature Implemented by Specification On by default in a page?

Static fields, static methods, enum constants and constructor calls

jakarta.el.StaticFieldELResolver working on an ELClass, which the container’s import resolver produces from a class name

EL 1.21, 1.24; Pages 2.10

yes, for java.lang and the Pages default packages; any other class needs an import

Array length

jakarta.el.ArrayELResolver

EL 1.22

yes (EL 6.0)

Records

jakarta.el.RecordELResolver, always read-only

EL 6.0 changes #201; Pages 2.10

yes (EL 6.0), listed in the Pages 4.0 chain just before BeanELResolver

java.util.Optional

jakarta.el.OptionalELResolver, always read-only

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.

Table 2. Access rules for static members (EL 1.24.1 and observed on Tomcat 11.0.26)
Rule What happens when it is broken Observed message or result

The member must be public and static

PropertyNotFoundException (package-private and private members are the same case)

No public static field named [SECRET] was found on exported class [org.example.bookshelf.Shelf]

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

PropertyNotWritableException

Writing to static fields (in this case field [counter] on class [org.example.bookshelf.Shelf]) is not permitted

The class must be imported (except java.lang and the Pages defaults)

the class name resolves to nothing, so the whole expression is null and prints as an empty string

no exception, no output

An imported class must be public, concrete and not an interface

ELException when the import is explicit

The class [jakarta.servlet.http.HttpServletResponse] must be public, in an exported package, non-abstract 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.

static.jsp
<%@ 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)}
Output of curl http://localhost:18080/g4elstatic/view/static
Boolean.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.

Table 3. Where an EL class name can come from
Source Effect

java.lang.*

always imported: Integer, Math, Boolean, String, System, Runtime, Thread and the rest

jakarta.servlet.*, jakarta.servlet.jsp.*, jakarta.servlet.http.*

implicitly imported by the container (Pages 4.0 1.10.1); concrete classes only, see the pitfall below

<%@ page import="a.b.C, d.e.*" %>

adds a class or a package for the scripting and the EL environment of that page

getELContext().getImportHandler().importClass("a.b.C")

adds a class at run time, from an ELContextListener (every page) or a tag handler (one page)

getELContext().getImportHandler().importPackage("a.b")

adds a package at run time

getELContext().getImportHandler().importStatic("a.b.C.NAME")

lets a static field or method be named without its class: ${NAME}, ${max(3, 7)}

include-prelude in a jsp-property-group

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:

ElImportsListener.java
@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.

imports.jsp
<%@ 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}
Output of curl http://localhost:18080/g4elstatic/view/imports
RoundingMode.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.

inc-main.jsp and the fragment inc-part.jsp
<%@ 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}]
Output of curl http://localhost:18080/g4elstatic/view/inc-main
main 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.

ctor.jsp
<%@ 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)}
Output of curl http://localhost:18080/g4elstatic/view/ctor
Boolean(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.

imports2.jsp: what the default imports reach, and what they do not
<%@ 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')}
Output of curl http://localhost:18080/g4elstatic/view/imports2
Cookie('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. Cookie is a class, so its constructor and .name work. HttpServletResponse and HttpServletRequest are interfaces and PageContext is 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 a page import, or in importStatic, does not help: it raises the ELException quoted in the table above. To use HttpServletResponse.SC_NOT_FOUND in 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: jakarta is looked up as an attribute, found nowhere, and the whole expression is null.

  • Empty is not an error. An unimported class and an interface constant both evaluate to null, because NotFoundELResolver always resolves, and a null prints as nothing. The errorOnELNotFound setting 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.

err-assign.jsp, err-private.jsp and err-nopub.jsp: assign to a static field, read a private one, and forget an import
<%@ 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}]
Table 4. Result of each request
Request Result

/g4elstatic/view/err-assign

HTTP 500: jakarta.el.PropertyNotWritableException: Writing to static fields (in this case field [counter] on class [org.example.bookshelf.Shelf]) is not permitted

/g4elstatic/view/err-private

HTTP 500: jakarta.el.PropertyNotFoundException: No public static field named [SECRET] was found on exported class [org.example.bookshelf.Shelf]

/g4elstatic/view/err-nopub

HTTP 200 with three empty values (Shelf, Status and the comparison book.status == Status.AVAILABLE, which is false)

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.

enum.jsp
<%@ 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()}
Output of curl http://localhost:18080/g4elstatic/view/enum
book.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:

err-enum.jsp
<%@ 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.

array.jsp
<%@ 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()}
Output of curl http://localhost:18080/g4elstatic/view/array
titles.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().

record.jsp
<%@ 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()}
Output of curl http://localhost:18080/g4elstatic/view/record
book.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.

err-record.jsp, err-record-write.jsp and err-record-hidden.jsp
<%@ 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}
Table 5. Result of each request
Request Result

/g4elstatic/view/err-record

HTTP 500: jakarta.el.PropertyNotFoundException: Property [recent] not found on type [org.example.bookshelf.Book]. isRecent() is a boolean method but not a component, so there is no recent property; call it as ${book.isRecent()}.

/g4elstatic/view/err-record-write

HTTP 500: jakarta.servlet.jsp.JspTagException: Invalid property in <set>: "title". A record is immutable: c:set with a target finds no writable property, and the record resolver’s setValue always fails.

/g4elstatic/view/err-record-hidden

HTTP 500: jakarta.el.ELException: Error reading [name] on type [org.example.bookshelf.Hidden]. The record Hidden is package-private, so the resolver in jakarta.el may not call its accessor even though the method is public. A record used in a page must itself be a public type.

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.

Table 6. The two Optionals
Class Where it comes from What EL does with it

java.util.Optional

a property or a method result in your model, such as Book.isbn()

by default nothing special: an ordinary object, whose methods (isPresent(), orElse(x)) are callable and whose properties do not exist. With OptionalELResolver registered it is transparent: EL looks through it to its content

The EL stream Optional (org.apache.el.stream.Optional in Tomcat)

the result of the terminal stream operations findFirst(), max(), min(), average() and similar, in EL’s own collection API

always has get(), ifPresent, orElse and orElseGet; the specification states it is a separate class from java.util.Optional

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

optional-basic.jsp
<%@ 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>
Output of 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>:

optional-nav.jsp
<%@ 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:

m-ispresent.jsp, m-orelse-empty.jsp and m-orelse-present.jsp (one expression each)
<%@ 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).

OptionalSupport.java and the context parameter in web.xml
@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.

Table 7. Behaviour of the same expressions without and with OptionalELResolver (observed on Tomcat 11.0.26)
Expression Plain WAR (no resolver) Optional-enabled WAR

${book.isbn}, ISBN present

Optional[978-0-06-051275-0]

978-0-06-051275-0

${noIsbn.isbn}, ISBN empty

Optional.empty

empty string (coerced from null)

<c:out value="${noIsbn.isbn}" default="n/a"/>

Optional.empty

n/a

${book.isbn == '978-0-06-051275-0'}

false

true

${noIsbn.isbn == null}

false

false

${empty noIsbn.isbn}

false

false

${empty noIsbn.isbn.toString()}

false

true

${rawOptional.title} (an Optional<Book>)

PropertyNotFoundException

The Dispossessed

${noIsbn.isbn.orElse('n/a')}

n/a

empty string: the call goes to the content, and an empty Optional answers null

${book.isbn.orElse('n/a')}

the ISBN

MethodNotFoundException: Method not found: class java.lang.String.orElse(java.lang.String)

${book.isbn.isPresent()}

true

MethodNotFoundException: Method not found: class java.lang.String.isPresent()

The Optional-enabled outputs, side by side with the plain ones above:

Output of 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.

optional-el.jsp
<%@ 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)}]
Output of 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

Table 8. Static access, record and Optional pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
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 NotFoundELResolver returns null

add <%@ page import="..." %>; enable errorOnELNotFound while developing

${HttpServletResponse.SC_NOT_FOUND} or ${PageContext.REQUEST_SCOPE} prints nothing (observed)

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

ELException: The class [...] must be public, in an exported package, non-abstract and not an interface (observed)

an explicit import or importStatic of an interface or abstract class

remove the import; use a concrete class

PropertyNotFoundException: No public static field named [X] was found on exported class (observed)

the field is package-private, private or missing, or the class is in a non-exported package

make it public static, or expose a static method

PropertyNotWritableException: Writing to static fields ... is not permitted (observed)

an assignment (=) to a static field

change it in Java, not in EL

${book.status == Status.AVAILABLE} is always false (observed)

Status is not imported, so the right side is null

import the enum; turn on errorOnELNotFound in development

ELException: Cannot convert [available] of type [class java.lang.String] to [class ...Status] (observed)

a string that is not a constant name compared with an enum

compare with the constant, or status.name() eq 'AVAILABLE'; validate input first

${books.length} fails with NumberFormatException for the input string "length" (observed)

length is only special on Java arrays

${books.size()} or ${fn:length(books)}

PropertyNotFoundException: Property [recent] not found on type [...Book] (observed)

the property is not a record component (isRecent() is a method)

${book.isRecent()}, or make it a component

ELException: Error reading [name] on type [...Hidden] with an IllegalAccessException (observed)

the record type is not public

declare the record public

JspTagException: Invalid property in <set> (observed)

c:set target= on an immutable record

build a new record in the servlet

PropertyNotFoundException: Property [title] not found on type [java.util.Optional] (observed)

navigating through an Optional with no OptionalELResolver registered

unwrap in the servlet, or register the resolver

MethodNotFoundException: Method not found: class java.lang.String.orElse(...) (observed)

the resolver is registered and the page still calls the Optional API

drop the call, or remove the resolver

${empty book.isbn} is false for an empty Optional (observed, both modes)

empty tests null, empty string, array, collection and map, never an Optional

${empty book.isbn.toString()} with the resolver, ${not book.isbn.isPresent()} without it, or unwrap earlier

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

get() on an empty EL stream Optional

orElse or orElseGet

Request input becomes code

a servlet or tag builds an EL string from a parameter and evaluates it with ExpressionFactory or ELProcessor

never; input is data, and the default imports reach Runtime, System and more

What Changed Recently

Table 9. Changes that touch static access, records and Optional
Change Detail

EL 6.0 (Jakarta EE 11)

Java 17 is the minimum; deprecated code is removed and the dependency on the java.desktop module becomes optional; RecordELResolver is new and on by default (#201); OptionalELResolver is new and off by default (#176); arrays get a length property (#175); module visibility is respected when calling class methods (#188); every reference to the Java SecurityManager is removed (#192)

EL 5.0 (Jakarta EE 10)

BeanELResolver also considers default methods; a lambda can be coerced to a functional interface; the getFeatureDescriptors() method of ELResolver is deprecated, with a default implementation, for removal in 6.0; the specification states that technologies that depend on EL may define default imports beyond java.lang

Pages 4.0 (Jakarta EE 11)

minimum Java 17; the getFeatureDescriptors() methods of its own resolvers are removed; the jakarta.servlet.jsp.el classes that were deprecated are removed; the Pages 4.0 chain lists RecordELResolver before BeanELResolver

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 ScopedAttributeELResolver for a separate import resolver

EL 6.1, Pages 4.1 (milestones)

milestone releases of both specifications are published (jakarta.el-api 6.1.0-M2 on Maven Central); nothing in them was verified for static access, records or Optional, so this page makes no claim about them

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.

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

Constants and helper methods reached through <%= Shelf.MAX_LOANS %> scriptlets, a <jsp:useBean> of a class that holds the value, or a custom tag

${Shelf.MAX_LOANS} and ${Shelf.describe(book)} with a page import, in a page whose scripting is switched off

EL functions declared in a TLD <function> element to call a static method (${fn:...})

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 <%= %> scriptlet in the test of c:if

${book.status == Status.AVAILABLE}, or the string form, which EL coerces

JSTL 1.0 rt libraries (c_rt) taking a Java expression in the attribute

plain EL in the attribute; JSTL 3.0 has one library per area (jakarta.tags.core and four more) and no rt variants

A bean is a class with getters and setters; no records (Java 14 and later)

records work with ${book.title} since EL 6.0 on any Pages 4.0 container, and a pre-6 container needs getters

The size of an array through a scriptlet, <%= titles.length %>

${titles.length} (EL 6.0) or ${fn:length(titles)}

No Optional (it came in Java 8)

java.util.Optional is an object; the resolver that looks through it is opt-in and the better habit is to unwrap in the servlet

javax.el, javax.servlet.jsp packages in a custom resolver or a tag

jakarta.el, jakarta.servlet.jsp since Jakarta EE 9

Struts 1 and Struts 2 pages that read static values through the Struts tag libraries or OGNL (@class@field)

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 page import does the job under the rules of this page

References