Tag Library Descriptors & EL Functions

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 Pages 4.0 (chapter 7 "Tag Extensions", section 2.11 "Functions" and section 3.2 "Taglib Map"), Jakarta Standard Tag Library 3.0 (chapter 14 "Tag Library Validators"; API jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2, implementation org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1), Jakarta Expression Language 6.0 and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21. Everything was verified on 2026-10-08: the rules against the Pages 4.0 specification and the TLD XML Schema web-jsptaglibrary_4_0.xsd, and every example by building it into a Maven WAR (context path /g6tld, with the tag handlers split between the WAR and a small tag-library JAR) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones that run produced. A Tag Library Descriptor is the contract between a tag library and the JSP container: an XML file that names the library (its URI and preferred prefix), lists the tags with their handler classes, attributes, body types and variables, lists the EL functions with the static methods that implement them, and optionally names a validator and listeners. The container reads it at translation time, long before any request, and uses it to check every use of the library; page authors and tools read the same file. The running scenario is the Bookshelf, a small catalogue of books; the library bs below has a rating tag, a newest tag and a handful of functions. Writing the handler classes themselves is covered by the two custom-tags pages of this section (simple tag handlers and classic tag handlers) and tag files by Tag Files & Layouts; this page covers what surrounds them: the descriptor, its discovery, its packaging and its validation.

The Bookshelf Used on This Page

All the examples are views of one WAR, g6tld.war, deployed under /g6tld, plus a separate JAR, bookshelf-ui, that ends up in WEB-INF/lib. A servlet mapped to /demo/* puts a small list of books in the request and forwards to /WEB-INF/views/<name>.jsp; the listings below are those views (the page directive, <%@ page contentType="text/html;charset=UTF-8" %>, is left out) and every output is what curl printed.

The layout of the WAR and of the tag-library JAR
g6tld.war
  WEB-INF/web.xml                      (jsp-config taglib map, section "The Explicit Map in web.xml")
  WEB-INF/tlds/bookshelf.tld           (tags, tag-file, functions, validator, listener)
  WEB-INF/tlds/scriptfree.tld          (ScriptFreeTLV configuration)
  WEB-INF/tlds/permitted.tld           (PermittedTaglibsTLV configuration)
  WEB-INF/tags/pill.tag                (tag file referenced from bookshelf.tld)
  WEB-INF/prelude-scriptfree.jspf      (include-prelude of a property group, section "ScriptFreeTLV and PermittedTaglibsTLV")
  WEB-INF/classes/bookshelf/*.class    (Book, BookFunctions, RatingTag, NewestTag, NewestInfo, RatingRangeTLV, TldListener, DemoServlet)
  WEB-INF/lib/bookshelf-ui-1.0.0.jar   (META-INF/bookshelf-ui.tld and bookshelf/ui/BadgeTag.class)
  WEB-INF/lib/jakarta.servlet.jsp.jstl-api-3.0.2.jar, jakarta.servlet.jsp.jstl-3.0.1.jar
The data model and the servlet that fills the request (imports omitted)
public record Book(long id, String title, String author, int year, int rating, String isbn) { }

@WebServlet("/demo/*")
public class DemoServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        req.setAttribute("books", List.of(
                new Book(1, "Dune", "Frank Herbert", 1965, 5, "9780441172719"),
                new Book(2, "Neuromancer", "William Gibson", 1984, 4, "9780441569595"),
                new Book(3, "The Left Hand of Darkness", "Ursula K. Le Guin", 1969, 4, "9780441478125")));
        req.setAttribute("blurb", "A desert planet, a spice, a boy who sees the future and a war of great houses.");
        String page = req.getPathInfo().substring(1);
        req.getRequestDispatcher("/WEB-INF/views/" + page + ".jsp").forward(req, resp);
    }
}

What a Tag Library Descriptor Is and Who Reads It

A tag library is a collection of actions (and, since JSP 2.0, EL functions) made available to a page by a taglib directive that names the library by a URI and chooses a prefix. The URI is only an identifier: it "may be any valid URI as long as it can be used to uniquely identify the semantics of the tag library", and it is not downloaded. The descriptor is the XML document that stands behind it. It has the file extension .tld, and the specification gives it five consumers.

Table 1. Who uses the descriptor and for what
Reader What it takes from the descriptor

The JSP container, at translation time

The URI to descriptor mapping, the handler class of every tag, the attributes (names, required, request-time or not, type), the body type, the scripting variables, the function signatures, the validator and the listeners. It enforces "all constraints described in the TLD" before it generates any Java: a page that breaks them is not compiled

The JSP container, at start-up

The listener elements: the container instantiates and registers the listeners of every descriptor it finds, before the application starts

Page authors

The URI, the suggested prefix (short-name), the descriptions and examples

Authoring tools and IDEs

Everything above plus the tool-only taglib-extension, tag-extension and function-extension elements, which "must not affect the behavior of a container"

Other tag libraries and frameworks

The same file, read without loading a single class, because the descriptor "can be read by tools without instantiating objects or loader classes"

Three pieces cooperate. The taglib directive in the page gives a URI and a prefix; the descriptor says what the library contains; and the Java classes (tag handlers, functions, a validator, TagExtraInfo classes, listeners) do the work. The classes fall in two groups with different lifetimes: those instantiated at translation time (TagLibraryValidator, TagExtraInfo) and those instantiated at request time (tag handlers). Both must be reachable from WEB-INF/classes or a JAR in WEB-INF/lib, and the translation-time class loader sees the same classes as the request-time one.

The Schema and the Skeleton of a Descriptor

Since JSP 2.0 the descriptor is described by an XML Schema. For Jakarta Pages 4.0 it is https://jakarta.ee/xml/ns/jakartaee/web-jsptaglibrary_4_0.xsd, in the namespace https://jakarta.ee/xml/ns/jakartaee; the version attribute of the root taglib element is required and fixed to 4.0 in that schema (the 3.0 and 3.1 schemas exist next to it and fix 3.0 and 3.1). The root element is a sequence, in this order: the description group (description, display-name, icon), tlib-version, short-name, uri, validator, listener, tag, tag-file, function and taglib-extension.

Table 2. The children of taglib in web-jsptaglibrary_4_0.xsd
Element Meaning

description, display-name, icon

Human and tool information; all optional

tlib-version

Required. The version of the library itself as a dewey decimal (1.0), not the JSP version

short-name

Required. A mnemonic name that tools use as the preferred prefix; "do not use white space, and do not start with digits or underscore"

uri

Optional. The public URI that identifies the library; it is what the implicit map of the container is built from (see below), and "leave it empty if it does not apply"

validator

Optional, at most one. A TagLibraryValidator class with initialisation parameters

listener

Zero or more. Servlet event listener classes shipped with the library

tag

Zero or more. A tag implemented by a Java handler class

tag-file

Zero or more. A tag implemented by a tag file, located by path

function

Zero or more. An EL function implemented by a public static method

taglib-extension

Zero or more. Tool-only extension elements

The names of tag and tag-file elements share one namespace (the schema declares name unique across both), and function names are unique among functions. A minimal descriptor, the head of the Bookshelf library, looks like this.

WEB-INF/tlds/bookshelf.tld, the header
<?xml version="1.0" encoding="UTF-8"?>
<taglib 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-jsptaglibrary_4_0.xsd"
        version="4.0">
    <description>Bookshelf tags and EL functions.</description>
    <display-name>Bookshelf</display-name>
    <tlib-version>1.0</tlib-version>
    <short-name>bs</short-name>
    <uri>https://example.org/jsp/bookshelf</uri>
    <!-- validator, listener, tag, tag-file and function elements follow -->
</taglib>

Tomcat 11.0.26 did not reject a descriptor that breaks the schema. A descriptor in the old namespace http://java.sun.com/xml/ns/javaee with version="2.1" and an element the schema does not know (jsp-version) was read without a message and its function ran (the page t-oldns of the verification WAR printed A). That is convenient for old libraries and unhelpful for new ones: a misspelt element is not reported, it is simply ignored, so a tag library author should check the descriptor against the schema in the build. The XML Schema and the XML text are also the contract for tools, which are stricter than Tomcat.

The tag Element

A tag element describes one action that is implemented by a Java class. The 4.0 schema fixes the order of its children: the description group, name, tag-class, tei-class, body-content, variable, attribute, dynamic-attributes, example and tag-extension.

Table 3. The children of tag
Element Meaning

name

The action name used after the prefix: rating in bs:rating. An NMTOKEN, unique in the library

tag-class

Required. The class that implements jakarta.servlet.jsp.tagext.JspTag: a SimpleTag or a classic Tag

tei-class

Optional. A TagExtraInfo subclass consulted at translation time for scripting variables and extra validation; "if this is not given, the class is not consulted"

body-content

Required, no default since JSP 2.0. empty, scriptless, tagdependent or JSP; see below

variable

Zero or more. Scripting-variable information stated directly in the descriptor

attribute

Zero or more. The attributes the tag accepts

dynamic-attributes

true if the handler also accepts attributes with names the descriptor does not list; the handler must implement jakarta.servlet.jsp.tagext.DynamicAttributes

example

An informal example for tools

Table 4. The four body-content values
Value The body of the tag

empty

Must be empty; the container rejects any body (and <bs:rating value="3">text</bs:rating> fails to translate)

scriptless

May contain template text, EL and action elements, but no scripting elements. "A reasonable default for simple tag handlers" when the tag can have a body

JSP

May contain any JSP syntax including scriptlets. Only classic tag handlers can use it

tagdependent

Is not interpreted as JSP at all: its text is passed to the handler uninterpreted (an embedded query language, for instance)

The next listing is the handler class of the rating tag. It is a plain SimpleTagSupport subclass; its properties are the attribute names, so the container calls setValue and setMax before doTag.

bookshelf/RatingTag.java
public class RatingTag extends SimpleTagSupport {
    private int value;
    private int max = 5;

    public void setValue(int value) { this.value = value; }
    public void setMax(int max) { this.max = max; }

    @Override
    public void doTag() throws JspException, IOException {
        if (value < 0 || value > max) {
            throw new JspTagException("rating " + value + " is outside 0.." + max);
        }
        getJspContext().getOut().write("★".repeat(value) + "☆".repeat(max - value));
    }
}
WEB-INF/tlds/bookshelf.tld, the rating tag
<tag>
    <description>Shows a rating as filled and empty stars.</description>
    <name>rating</name>
    <tag-class>bookshelf.RatingTag</tag-class>
    <body-content>empty</body-content>
    <attribute>
        <description>The rating, from 0 to max.</description>
        <name>value</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>int</type>
    </attribute>
    <attribute>
        <name>max</name>
        <rtexprvalue>true</rtexprvalue>
        <type>int</type>
    </attribute>
</tag>

Attributes: required, rtexprvalue, type, fragment and Deferred Values

Each attribute child describes one attribute. The schema fixes the order: description, name, required, then either the group rtexprvalue, type and one of deferred-value or deferred-method, or the single element fragment.

Table 5. The children of attribute
Element Meaning

name

The attribute name, a Java identifier; it must match a setter of the handler (value needs setValue)

required

true or false; the default is false

rtexprvalue

Whether the value may be computed at request time: a scriptlet expression or, as the specification puts it in section 1.14.1, an EL expression ${...} or #{...}. The default is false: the value is a constant fixed at translation time

type

The Java type of the value; java.lang.Object if omitted, and int, boolean and the other primitives are legal (the container converts string constants and EL results)

fragment

true if the attribute is a jakarta.servlet.jsp.tagext.JspFragment: dynamic content the handler can evaluate as often as it needs; it is mutually exclusive with rtexprvalue, type and the deferred elements, and since a fragment attribute is always translated as a fragment it cannot hold a scriptlet expression

deferred-value

The value is passed to the handler as a jakarta.el.ValueExpression, evaluated by the handler (the optional type child is the type it will be coerced to; Object by default). #{...} expressions are the usual form

deferred-method

The value is passed as a jakarta.el.MethodExpression; the optional method-signature child gives its signature (void method() by default)

Section 1.14.1 states the rule that makes rtexprvalue matter: "by default, except in tag files, all attributes have page translation-time semantics. Attempting to specify a scriptlet expression or EL expression as the value for an attribute that (by default or otherwise) has page translation time semantics is illegal, and will result in a fatal translation error."

The rating page below uses the tag twice per book, once with the default scale and once with max="10", and also the second tag of the library, newest, whose descriptor entry is shown after it.

rating.jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/jsp/bookshelf" %>
<bs:newest books="${books}" var="newest"/>
<p>newest: ${newest.title} (${newest.year})</p>
<c:forEach var="book" items="${books}">
<p>${book.title}: <bs:rating value="${book.rating}"/> <bs:rating value="${book.rating}" max="10"/></p>
</c:forEach>
The rendered output (curl /g6tld/demo/rating)
<p>newest: Neuromancer (1984)</p>
<p>Dune: ★★★★★ ★★★★★☆☆☆☆☆</p>
<p>Neuromancer: ★★★★☆ ★★★★☆☆☆☆☆☆</p>
<p>The Left Hand of Darkness: ★★★★☆ ★★★★☆☆☆☆☆☆</p>

Scripting Variables and the variable Element

A tag can create a variable for the page. A SimpleTag does it by calling getJspContext().setAttribute(name, value, scope); the descriptor can additionally declare the variable so that scripting elements after the tag see it as a Java local variable. The variable element has the children description, then one of name-given (a constant name) or name-from-attribute (the name of an attribute whose translation-time value is the name of the variable), then variable-class (java.lang.String by default), declare (true by default) and scope, one of NESTED (the default, visible between the start and end tags), AT_BEGIN or AT_END (visible after the end tag).

The handler of the newest tag
public class NewestTag extends SimpleTagSupport {
    private Collection<Book> books;
    private String var;

    public void setBooks(Collection<Book> books) { this.books = books; }
    public void setVar(String var) { this.var = var; }

    @Override
    public void doTag() throws JspException, IOException {
        Book newest = books.stream().max(Comparator.comparingInt(Book::year)).orElse(null);
        getJspContext().setAttribute(var, newest, PageContext.PAGE_SCOPE);
    }
}
WEB-INF/tlds/bookshelf.tld, the newest tag with a variable element
<tag>
    <description>Finds the most recent book and exposes it under var.</description>
    <name>newest</name>
    <tag-class>bookshelf.NewestTag</tag-class>
    <body-content>empty</body-content>
    <variable>
        <name-from-attribute>var</name-from-attribute>
        <variable-class>bookshelf.Book</variable-class>
        <scope>AT_END</scope>
    </variable>
    <attribute>
        <name>books</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>java.util.Collection</type>
    </attribute>
    <attribute>
        <name>var</name>
        <required>true</required>
        <rtexprvalue>false</rtexprvalue>
    </attribute>
</tag>

The attribute var has rtexprvalue set to false, which is required for name-from-attribute: the name must be a translation-time constant. The page above printed newest: Neuromancer (1984) because the Expression Language reads the variable from the page scope whether or not it is declared; the variable element matters only to scriptlets and to tools. Since JSP 2.0 pages should read such variables with EL, and the scriptlet form is on the Scripting Elements (Legacy) page.

TagExtraInfo: Variables and Translation-Time Checks in Java

When the variable’s name or type depends on more than a constant or an attribute value, or when the tag needs a check that a descriptor cannot express, the descriptor names a TagExtraInfo class in tei-class. The container calls getVariableInfo(TagData) to learn the variables (each a VariableInfo with a name, a class name, a declare flag and a scope constant NESTED, AT_BEGIN or AT_END) and validate(TagData) to run extra checks; TagData gives the attribute values that are known at translation time (getAttributeString("var")) and getId() identifies the action. The schema says that a tag with variable children must not have a TagExtraInfo class that returns variables: use one mechanism or the other. Section 7.4.1.3 adds that since JSP 2.0 validate is the preferred method, the older isValid still works, and implementing both is not recommended.

bookshelf/NewestInfo.java
public class NewestInfo extends TagExtraInfo {

    @Override
    public VariableInfo[] getVariableInfo(TagData data) {
        return new VariableInfo[] {
            new VariableInfo(data.getAttributeString("var"), "bookshelf.Book", true, VariableInfo.AT_END)
        };
    }

    @Override
    public ValidationMessage[] validate(TagData data) {
        String var = data.getAttributeString("var");
        if (var == null || !Character.isJavaIdentifierStart(var.charAt(0))) {
            return new ValidationMessage[] {
                new ValidationMessage(data.getId(), "var must be a Java identifier, not [" + var + "]")
            };
        }
        return null;
    }
}
WEB-INF/tlds/bookshelf.tld, the latest tag (same handler, TagExtraInfo instead of a variable element)
<tag>
    <name>latest</name>
    <tag-class>bookshelf.NewestTag</tag-class>
    <tei-class>bookshelf.NewestInfo</tei-class>
    <body-content>empty</body-content>
    <attribute>
        <name>books</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>java.util.Collection</type>
    </attribute>
    <attribute>
        <name>var</name>
        <required>true</required>
    </attribute>
</tag>
latest.jsp: the variable is visible to EL and, because it is declared, to a scriptlet (legacy form, shown only to prove the declaration)
<%@ taglib prefix="bs" uri="https://example.org/jsp/bookshelf" %>
<bs:latest books="${books}" var="newest"/>
<p>EL: ${newest.title}</p>
<p>scriptlet: <%= newest.title().toUpperCase() %></p>
The rendered output
<p>EL: Neuromancer</p>
<p>scriptlet: NEUROMANCER</p>

With var="1st" the page does not translate; Tomcat reports the message the validate method returned:

org.apache.jasper.JasperException: /WEB-INF/views/latest-bad.jsp (line: [3], column: [0])
<h3>Validation error messages from TagExtraInfo for [bs:latest]</h3><p>var must be a Java identifier, not [1st]</p>

Dynamic Attributes and tag-file Elements

Two more children of the tag library complete the list of ways to define an action.

A tag that mimics an HTML element cannot know in advance which attributes a page author will pass. Setting <dynamic-attributes>true</dynamic-attributes> in the tag element, and implementing DynamicAttributes in the handler, makes the container hand every attribute that is not declared to setDynamicAttribute(uri, localName, value). The bookshelf-ui JAR has a badge tag that copies the extra attributes into a span.

bookshelf/ui/BadgeTag.java, in the bookshelf-ui JAR
public class BadgeTag extends SimpleTagSupport implements DynamicAttributes {
    private final Map<String, Object> extra = new LinkedHashMap<>();
    private String kind = "info";

    public void setKind(String kind) { this.kind = kind; }

    @Override
    public void setDynamicAttribute(String uri, String localName, Object value) {
        extra.put(localName, value);
    }

    @Override
    public void doTag() throws JspException, IOException {
        StringWriter body = new StringWriter();
        if (getJspBody() != null) {
            getJspBody().invoke(body);
        }
        StringBuilder html = new StringBuilder("<span class=\"badge badge-").append(kind).append('"');
        extra.forEach((k, v) -> html.append(' ').append(k).append("=\"").append(v).append('"'));
        html.append('>').append(body).append("</span>");
        getJspContext().getOut().write(html.toString());
    }
}
META-INF/bookshelf-ui.tld in the JAR
<taglib 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-jsptaglibrary_4_0.xsd"
        version="4.0">
    <description>Reusable Bookshelf user interface tags, packaged in a JAR.</description>
    <tlib-version>1.0</tlib-version>
    <short-name>bsui</short-name>
    <uri>https://example.org/jsp/bookshelf-ui</uri>
    <tag>
        <description>A small badge with free-form HTML attributes.</description>
        <name>badge</name>
        <tag-class>bookshelf.ui.BadgeTag</tag-class>
        <body-content>scriptless</body-content>
        <attribute>
            <name>kind</name>
            <rtexprvalue>true</rtexprvalue>
        </attribute>
        <dynamic-attributes>true</dynamic-attributes>
    </tag>
</taglib>
jar.jsp and its output
<%@ taglib prefix="ui" uri="https://example.org/jsp/bookshelf-ui" %>
<ui:badge kind="warn" title="Sold out" data-sku="B-1">Dune</ui:badge>
<span class="badge badge-warn" title="Sold out" data-sku="B-1">Dune</span>

The handler writes the dynamic values as they come, without escaping, which is exactly what a library author must not do with request data; a real badge would escape them with the Java side of fn:escapeXml or an encoder (see the pitfalls table).

A tag-file element does the same job for a tag file: name is the action name and path the location of the .tag file, which "must begin with /WEB-INF/tags if the .tag file resides in the WAR, or /META-INF/tags if the .tag file resides in a JAR". It exists so that a tag file can be given an explicit name, a URI shared with Java tags and a place in a library, instead of being reached through the implicit library of a tagdir directive. How to write the tag file is on the tag-files page of this section; the descriptor side is one element.

WEB-INF/tags/pill.tag
<%@ tag body-content="empty" %>
<%@ attribute name="text" required="true" %>
<span class="pill">${text}</span>
WEB-INF/tlds/bookshelf.tld, the tag-file element
<tag-file>
    <description>A small label implemented as a tag file.</description>
    <name>pill</name>
    <path>/WEB-INF/tags/pill.tag</path>
</tag-file>
pill.jsp, with the output printed by curl
<%@ taglib prefix="bs" uri="https://example.org/jsp/bookshelf" %>
<bs:pill text="New"/>
<span class="pill">New</span>

EL Functions: the function Element

Expression Language functions are qualified calls, ${ns:f(a1, a2)}, that the container maps to public static methods of public classes. The map is the descriptor: section 2.11.2 says "each such function is given a name (as seen in the EL), and a static method in a specific class that will implement the function". A function element has the children description, display-name, icon, name, function-class, function-signature, example and function-extension.

Table 6. The children of function
Element Meaning

name

The name seen in EL: initials in ${bs:initials(book.author)}; unique in the library

function-class

The fully qualified name of a public class that holds the method

function-signature

The method as Java writes it, with a fully qualified return type, the method name and the fully qualified parameter types in parentheses, separated by commas: java.lang.String isbn13(java.lang.String). The parameter names are not part of it; primitives are allowed (boolean isClassic(int))

example

Informal usage for tools, such as ${bs:initials(book.author)}

The EL name and the Java method name are independent: a descriptor of the verification WAR declared name f for the method initials, and ${t:f('abc')} printed A. The Bookshelf library gives each function the name of its method and has five of them.

The function class (the static methods; imports omitted)
public final class BookFunctions {
    private BookFunctions() { }

    public static String initials(String name) {
        if (name == null || name.isBlank()) { return ""; }
        StringBuilder out = new StringBuilder();
        for (String part : name.trim().split("\\s+")) {
            out.append(Character.toUpperCase(part.charAt(0)));
        }
        return out.toString();
    }

    public static String isbn13(String digits) {
        if (digits == null || !digits.matches("\\d{13}")) {
            throw new IllegalArgumentException("not an ISBN-13: " + digits);
        }
        return digits.substring(0, 3) + "-" + digits.charAt(3) + "-" + digits.substring(4, 7) + "-"
                + digits.substring(7, 12) + "-" + digits.charAt(12);
    }

    public static boolean isClassic(int year) { return year < 1980; }

    public static int pages(int items, int perPage) { return (items + perPage - 1) / perPage; }

    public static String excerpt(String text, int max) {
        if (text == null || text.length() <= max) { return text; }
        return text.substring(0, max).stripTrailing() + "...";
    }
}
WEB-INF/tlds/bookshelf.tld, the function elements
<function>
    <description>Initials of a name.</description>
    <name>initials</name>
    <function-class>bookshelf.BookFunctions</function-class>
    <function-signature>java.lang.String initials(java.lang.String)</function-signature>
    <example>${bs:initials(book.author)}</example>
</function>
<function>
    <name>isbn13</name>
    <function-class>bookshelf.BookFunctions</function-class>
    <function-signature>java.lang.String isbn13(java.lang.String)</function-signature>
</function>
<function>
    <name>isClassic</name>
    <function-class>bookshelf.BookFunctions</function-class>
    <function-signature>boolean isClassic(int)</function-signature>
</function>
<function>
    <name>pages</name>
    <function-class>bookshelf.BookFunctions</function-class>
    <function-signature>int pages(int, int)</function-signature>
</function>
<function>
    <name>excerpt</name>
    <function-class>bookshelf.BookFunctions</function-class>
    <function-signature>java.lang.String excerpt(java.lang.String, int)</function-signature>
</function>
functions.jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/jsp/bookshelf" %>
<ul>
<c:forEach var="book" items="${books}">
  <li>${book.title} by ${bs:initials(book.author)} (${bs:isbn13(book.isbn)})<c:if test="${bs:isClassic(book.year)}"> classic</c:if></li>
</c:forEach>
</ul>
<p>pages: ${bs:pages(books.size(), 2)}</p>
<p>${bs:excerpt(blurb, 30)}</p>
The rendered output (curl /g6tld/demo/functions)
<ul>
  <li>Dune by FH (978-0-441-17271-9) classic</li>
  <li>Neuromancer by WG (978-0-441-56959-5)</li>
  <li>The Left Hand of Darkness by UKLG (978-0-441-47812-5) classic</li>
</ul>
<p>pages: 2</p>
<p>A desert planet, a spice, a bo...</p>

How a Function Call Is Resolved

Section 2.11.4 gives the algorithm, and the table below pairs each step with the error Tomcat 11.0.26 produced when the step failed. Binding happens when the expression is parsed, which for a page is during translation; the arguments are evaluated at request time and coerced to the types in the signature by the usual EL rules.

Table 7. Resolution steps of a function call and what failed
Step and the mistake What Tomcat 11.0.26 reported

Find the TLD for the prefix; the prefix is unknown (${x:f('a')} without a taglib directive)

JasperException: /WEB-INF/views/unknown-prefix.jsp (line: [2], column: [5]) The attribute prefix [x] does not correspond to any imported tag library

Find the function with that name in the TLD (${bs:nothing('a')})

JasperException: /WEB-INF/views/unknown-fn.jsp (line: [3], column: [5]) The function [nothing] cannot be located with the specified prefix

Count the arguments (${bs:pages(5)})

JasperException: jakarta.el.ELException: Function [bs:pages] specifies [2] params, but [1] were declared

Load function-class (class missing)

JasperException: The class [bookshelf.Missing] specified in TLD for the function [t:f] cannot be found: [bookshelf.Missing]

Find the method named and typed by function-signature (name or parameter types that do not exist, java.lang.Integer for an String parameter)

JasperException: Method [nothing] for function [f] not found in class [bookshelf.BookFunctions], and the same message with the real method name when only the parameter types are wrong

Two function elements with the same name

JasperException: Duplicate function name [f] in tag library [https://example.org/jsp/dupfn]

Run the method; it throws (${bs:isbn13('123')})

Request-time jakarta.el.ELException: Problems calling function [bs:isbn13], whose cause is the IllegalArgumentException: not an ISBN-13: 123; the specification says exactly this: the exception "must be wrapped in an ELException"

Call a function without a prefix (${initials('Frank Herbert')} in a page whose library has the prefix bs)

Request-time jakarta.el.ELException: Function [:initials] not found. Only a JSP document with a default namespace can omit the prefix, and since EL 3.0 such calls are not validated at translation time

Section 2.11.2 also requires that the method be static ("failure to satisfy these requirements shall result in a translation-time error"). Tomcat 11.0.26 did not enforce it: a descriptor that pointed to a non-static method (java.lang.String notStatic(java.lang.String)) translated, and the request failed with a NullPointerException: Cannot invoke "Object.getClass()" because "obj" is null from Method.invoke. The practical consequence is the same: such a page does not work, but the error is a confusing stack trace at request time, not a message at translation time.

Functions and EL 6.0 Method Calls

Functions predate the features that now replace many of them. Since EL 2.2 a page can call a method on an object (${book.title.toUpperCase()}), since EL 3.0 it can import a class and call its static methods (${Math.max(a, b)} after an import, see the static access page), and EL 6.0 keeps both. A function declared in a descriptor still has three advantages: it is bound and checked at translation time; it hides which class implements it, so a page author does not need to know or import the class; and it is the only way to offer a library of helpers to pages that run with EL method calls and class imports restricted by configuration (see the EL configuration page). Where the helper belongs to a domain class, a method on that class called from EL is simpler than a function.

How a taglib URI Becomes a Descriptor

The page says uri="https://example.org/jsp/bookshelf"; the container must find the file. Section 7.3.2 calls the answer the TLD resource path and builds a taglib map from URIs to resource paths, in a fixed order of precedence:

flowchart TD d["taglib directive: uri attribute"] --> m{"entry for the uri in the taglib map?"} m -- yes --> p["taglib-location of the entry is the TLD resource path"] m -- no --> a{"uri is an absolute URI such as https://..."} a -- yes --> e["fatal translation error: the absolute uri cannot be resolved"] a -- no --> r{"uri starts with a slash?"} r -- yes --> rp["the uri itself is the TLD resource path"] r -- no --> rl["the uri resolved against the page is the TLD resource path"] p --> t["resource path must be a .tld file or a JAR with META-INF/taglib.tld"] rp --> t rl --> t t --> ok["descriptor read and cached for the page"]

The map itself is assembled from five sources, highest precedence first:

Table 8. Sources of the taglib map, from highest to lowest precedence
# Source What it contributes

1

The entries of the Jakarta EE platform, when the container is a compliant EE one

The Standard Tag Library and Faces libraries; Tomcat is not an EE platform, so the JSTL JARs in WEB-INF/lib provide those URIs instead (as in the other pages of this section, for example JSTL Core)

2

The taglib map in web.xml (jsp-config / taglib)

An explicit URI to location pair

3

Implicit entries from TLDs in JARs in WEB-INF/lib

Every .tld under META-INF that has a uri element

4

Implicit entries from TLDs under WEB-INF

Every .tld below /WEB-INF that has a uri element

5

Implicit entries known to the container

Libraries that the container implements itself, which must behave as the descriptor in the specification says

The numbers 3 and 4 are the reason a library JAR can be "dropped in and used immediately through its stable URIs". The map ignores duplicates: an implicit entry is added only when the map has no entry with the same URI.

Implicit Entries: Drop a Descriptor In and Use Its URI

For WEB-INF/tlds/bookshelf.tld nothing was configured: the container searched /WEB-INF and the JARs in /WEB-INF/lib for .tld files, saw <uri>https://example.org/jsp/bookshelf</uri>; and built the entry itself. That is why rating.jsp above and jar.jsp in the JAR section work with only a taglib directive. The conditions, from section 7.3.4: the file extension is .tld; it lives under /WEB-INF or a subdirectory, or inside a JAR in /WEB-INF/lib under /META-INF or a subdirectory; and the uri element is present. A descriptor without uri is not registered and can only be reached by an explicit entry or by a path. The order in which the files are searched is implementation specific "and should not be relied on", which matters when two descriptors claim the same URI (see the pitfalls).

The Explicit Map in web.xml

The taglib element of jsp-config binds a URI to a location; section 3.2 says the body of taglib-uri may be an absolute or a relative URI, "there should be no entries in web.xml with the same taglib-uri value", and taglib-location is the location of the descriptor file. The jsp-config element also holds the jsp-property-group elements, described on the configuration and property groups page. A relative taglib-location without a leading slash is resolved against /WEB-INF/web.xml.

web.xml of the verification WAR
<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">
    <jsp-config>
        <taglib>
            <taglib-uri>/bs-legacy</taglib-uri>
            <taglib-location>/WEB-INF/tlds/bookshelf.tld</taglib-location>
        </taglib>
    </jsp-config>
</web-app>

The explicit entry gives a short name that does not depend on the file location, which is the only advantage left over the implicit map: it lets an application keep the old URI of a library that was renamed, or give two names to the same library during a migration. The three directives of the next page point at the same descriptor in the three ways the map allows: the explicit entry /bs-legacy, the fallback rule (a path starting with a slash that is not in the map is taken as the resource path), and the implicit entry.

mapped.jsp
<%@ taglib prefix="a" uri="/bs-legacy" %>
<%@ taglib prefix="b" uri="/WEB-INF/tlds/bookshelf.tld" %>
<%@ taglib prefix="c" uri="https://example.org/jsp/bookshelf" %>
<p>${a:initials('Frank Herbert')} ${b:initials('William Gibson')} ${c:initials('Ursula Le Guin')}</p>
<a:rating value="3"/>
The rendered output
<p>FH WG ULG</p>
★★★☆☆

The fallback "is convenient for quick development at the expense of less flexibility and accountability": the path is in every page, and moving the file means editing all the pages. Section 7.3.9 adds one more rule about the first source of the table: a container "must always use the mapping specified for a URI in the web.xml deployment descriptor if present", so an application that wants its own copy of a well-known library on an EE platform removes the container’s mapping rather than the library.

What an Unresolved URI Looks Like

A URI that no source provides is a fatal translation error; the specification says so, and Tomcat 11.0.26 phrases it like this (page unknown-uri, uri="https://example.org/jsp/nothing"):

org.apache.jasper.JasperException: The absolute uri: [https://example.org/jsp/nothing] cannot be resolved in either web.xml or the jar files deployed with this application

The same message appears when the library JAR is missing from WEB-INF/lib, when its descriptor has no uri, when the descriptor is in a place the scanner does not look (see the next section) and when the JAR is on the skip list. A taglib directive that follows the first use of its prefix is a different error: The prefix [bs] specified in this tag directive has been previously used by an action in file [/WEB-INF/views/prefix-late.jsp] line [2].

Packaging: Descriptors in JARs and Where the Container Looks

A packaged tag library is a JAR with at least one descriptor. Section 7.2.1 requires every JSP container and authoring tool to accept it, and 7.3.1 fixes the locations:

Table 9. Where descriptors may be
Location Rule

Inside a JAR

Under META-INF or a subdirectory, for example META-INF/bookshelf-ui.tld. A JAR can hold several descriptors (several libraries), which is how the Standard Tag Library JAR ships c.tld, fmt.tld, fn.tld, sql.tld, x.tld and the validator libraries side by side

In the web application

Under /WEB-INF or a subdirectory, such as /WEB-INF/tlds

Not allowed

In /WEB-INF/classes or /WEB-INF/lib (as loose files), and in /WEB-INF/tags or below, "unless named implicit.tld and intended to configure an implicit tag library with its JSP version and tlib-version"

The same chapter states that "JSP 1.1 allowed only a single TLD, in META-INF/taglib.tld`". That file name is still meaningful: the TLD resource path of a taglib directive may name a JAR instead of a `.tld, and then the descriptor is that file inside the JAR (rule 7.3.2); it is the one use of the old file name that the current text keeps.

The verification WAR has a loose misplaced.tld in WEB-INF/classes (copied there by Maven from src/main/resources) with a valid uri; a page that used it failed with the unresolved-URI error above, which confirms that Tomcat does not search WEB-INF/classes. The descriptor under WEB-INF/tlds and the one in the JAR were found. The JAR is built by an ordinary Maven module and added as a dependency:

pom.xml of the library module and its use in the WAR (dependencies only)
<!-- tags/pom.xml: the JAR with META-INF/bookshelf-ui.tld in src/main/resources -->
<artifactId>bookshelf-ui</artifactId>
<packaging>jar</packaging>
<dependencies>
    <dependency>
        <groupId>jakarta.servlet.jsp</groupId>
        <artifactId>jakarta.servlet.jsp-api</artifactId>
        <version>4.0.0</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<!-- web/pom.xml: the WAR that uses it -->
<dependency>
    <groupId>org.example</groupId>
    <artifactId>bookshelf-ui</artifactId>
    <version>1.0.0</version>
</dependency>

How Tomcat Scans and How to Speed It Up

Tomcat finds the descriptors in JARs by scanning the JARs that its JAR scanner is configured to visit (those in WEB-INF/lib first of all) for META-INF/*.tld. Scanning hundreds of JARs that do not contain tag libraries is slow, and Tomcat tells you so with an INFO message logged by org.apache.jasper.servlet.TldScanner: "At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time." The remedy is the Jar Scan Filter of the Context: the tldSkip and tldScan attributes are comma-separated file-name patterns (* and ? wildcards, case-sensitive), defaultTldScan says what happens to JARs that match neither, and the defaults come from the tomcat.util.scan.StandardJarScanFilter.jarsToSkip and jarsToScan properties of conf/catalina.properties. The consequence cuts both ways: a library JAR whose name matches a skip pattern is never scanned and its URI is unresolved, and a JAR that should be scanned but is skipped looks exactly like a missing library.

Validation: Three Layers

Section 7.4 distinguishes where a page can be validated and why: for request-time semantics ("a subelement may require the information from some enclosing element"), for authoring-tool support, and for methodological constraints ("a development group may want to constrain the way some features are used"). The Pages 4.0 specification gives a layered answer, and the Bookshelf library uses all three layers.

Table 10. The validation mechanisms, from the cheapest to the most flexible
Mechanism When What it can check

The attribute and body rules in the descriptor

Translation

Mandatory attributes, which attributes exist, whether an attribute accepts a request-time expression, whether the body must be empty. "All constraints described in the TLD must be enforced"

TagExtraInfo.validate(TagData)

Translation, per tag

The attribute values known at translation time of one action, and the scripting variables

TagLibraryValidator.validate(prefix, uri, PageData)

Translation, whole page

Anything about the page: the order, nesting and counts of actions, the use of other libraries, the presence of scripting

A JspException from the handler

Request time

Values only known at request time; "if uncaught, this object will invoke the errorpage mechanism"

The first layer needs no code, and its errors are the most common ones a library user meets. Each of these pages failed to translate on Tomcat 11.0.26 with the message in the right-hand column:

Table 11. Errors reported from the descriptor rules
The page The message

<bs:rating/> (no value)

JasperException: /WEB-INF/views/missing-attr.jsp (line: [3], column: [0]) According to the TLD or the tag file, attribute [value] is mandatory for tag [rating]

<bs:rating value="3" size="2"/>

JasperException: /WEB-INF/views/bad-attr.jsp (line: [3], column: [0]) Attribute [size] invalid for tag [rating] according to TLD

<bs:rating value="3">text</bs:rating>

JasperException: /WEB-INF/views/body-in-empty.jsp (line: [3], column: [0]) According to TLD, tag [bs:rating] must be empty, but is not

<bs:newest books="${books}" var="${'n'}"/> (an expression where rtexprvalue is false)

JasperException: /WEB-INF/views/static-attr.jsp (line: [3], column: [0]) According to TLD or attribute directive in tag file, attribute [var] does not accept any expressions

<bs:rating value="abc"/> (a constant that cannot become an int)

JasperException: Unable to compile class for JSP, with java.lang.NumberFormatException: For input string: "abc" among the causes of the log; the page-level message does not name the attribute

A tag whose descriptor says <body-content>JSP</body-content> and whose class is a SimpleTag

JasperException: /WEB-INF/views/t-bodyjsp.jsp (line: [3], column: [0]) The TLD for the class [bookshelf.RatingTag] specifies an invalid body-content (JSP) for a SimpleTag.

TagLibraryValidator

A TagLibraryValidator looks at the whole page, through its XML view: the page converted to the XML form that the specification defines for JSP documents (see the JSP documents page). The descriptor names the class and its initialisation parameters; the container instantiates it, calls setInitParameters(Map) with the init-param values and then validate(prefix, uri, PageData) once for every page that imports the library. PageData.getInputStream() is the XML view, and every element in it carries a jsp:id attribute (namespace http://java.sun.com/JSP/Page) that the container assigns, so that a message can point at an element. The method returns null for a valid page or an array of ValidationMessage(id, message) objects.

The Bookshelf validator rejects rating actions whose literal value exceeds a limit the descriptor configures. It reads the XML view with a namespace-aware SAX parser, with DOCTYPE declarations disallowed and secure processing on (the input is generated by the container, but a validator is a parser and should be configured like one).

bookshelf/RatingRangeTLV.java
public class RatingRangeTLV extends TagLibraryValidator {

    private static final String JSP_NS = "http://java.sun.com/JSP/Page";

    @Override
    public ValidationMessage[] validate(String prefix, String uri, PageData page) {
        int allowed = Integer.parseInt(String.valueOf(getInitParameters().getOrDefault("maxAllowed", "5")));
        List<ValidationMessage> problems = new ArrayList<>();
        try (InputStream xml = page.getInputStream()) {
            SAXParserFactory factory = SAXParserFactory.newInstance();
            factory.setNamespaceAware(true);
            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
            factory.newSAXParser().parse(xml, new DefaultHandler() {
                @Override
                public void startElement(String ns, String local, String qName, Attributes atts) {
                    if (!uri.equals(ns) || !"rating".equals(local)) {
                        return;
                    }
                    String value = atts.getValue("value");
                    if (value != null && value.matches("\\d+") && Integer.parseInt(value) > allowed) {
                        problems.add(new ValidationMessage(atts.getValue(JSP_NS, "id"),
                                "<" + prefix + ":rating> value " + value
                                        + " is above the allowed maximum " + allowed));
                    }
                }
            });
        } catch (Exception e) {
            return new ValidationMessage[] {new ValidationMessage(null, "cannot read the XML view: " + e)};
        }
        return problems.isEmpty() ? null : problems.toArray(new ValidationMessage[0]);
    }
}
WEB-INF/tlds/bookshelf.tld, the validator element (children of taglib, before listener)
<validator>
    <description>Rejects literal rating values above maxAllowed.</description>
    <validator-class>bookshelf.RatingRangeTLV</validator-class>
    <init-param>
        <param-name>maxAllowed</param-name>
        <param-value>5</param-value>
    </init-param>
</validator>
bad-range.jsp
<%@ taglib prefix="bs" uri="https://example.org/jsp/bookshelf" %>
<bs:rating value="7"/>
Tomcat 11.0.26 translation error
org.apache.jasper.JasperException:
<h3>Validation error messages from TagLibraryValidator for [bs] in [/WEB-INF/views/bad-range.jsp]</h3><p>4: <bs:rating> value 7 is above the allowed maximum 5</p>

Three things in that output deserve attention. The 4: is the jsp:id of the element, an identifier the container numbers, and not a line number (the action is on line 3 of the page); tools that turn it into a position need the container’s map. The validator saw only the literal 7: <bs:rating value="${7}"/> and <bs:rating value="${books[0].rating + 3}"/> both passed translation and failed at request time with the handler’s own JspTagException: rating 7 is outside 0..5 and rating 8 is outside 0..5, which is why the handler keeps its check: a validator can only inspect what the page text says. And the validator ran for every page that imports bs, even pages that do not use rating: validation is per page, not per tag, so it must be quick and must not assume that the tag it checks is present.

ScriptFreeTLV and PermittedTaglibsTLV

Chapter 14 of the Standard Tag Library specification provides two ready-made validators ("reusable validation: generic validation that custom tag-library authors might wish to incorporate in their own tag libraries"). They live in the API JAR, in the package jakarta.servlet.jsp.jstl.tlv, and a library only needs a descriptor that names them. A library can exist "simply to provide site-specific validation logic": it has no tags.

Table 12. The two validators
Class What it enforces

jakarta.servlet.jsp.jstl.tlv.ScriptFreeTLV

A page without scripting elements. Parameters allowDeclarations, allowScriptlets, allowExpressions (top-level <%= %>) and allowRTExpressions (scriptlet expressions used as attribute values), each true or false

jakarta.servlet.jsp.jstl.tlv.PermittedTaglibsTLV

A page that imports only listed libraries. The parameter permittedTaglibs is a whitespace-separated list of URIs

The Standard Tag Library implementation JAR already contains two such descriptors, META-INF/scriptfree.tld (URI jakarta.tags.scriptfree, all four parameters false, so even top-level expressions are rejected) and META-INF/permittedTaglibs.tld (URI jakarta.tags.permittedTaglibs). A site that wants other settings writes its own descriptor, as the specification’s chapter does. The Bookshelf one allows expressions but not scriptlets or declarations:

WEB-INF/tlds/scriptfree.tld
<taglib 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-jsptaglibrary_4_0.xsd"
        version="4.0">
    <description>Validates pages to prohibit scriptlets and declarations.</description>
    <tlib-version>1.0</tlib-version>
    <short-name>scriptfree</short-name>
    <uri>https://example.org/jsp/scriptfree</uri>
    <validator>
        <validator-class>jakarta.servlet.jsp.jstl.tlv.ScriptFreeTLV</validator-class>
        <init-param>
            <param-name>allowDeclarations</param-name>
            <param-value>false</param-value>
        </init-param>
        <init-param>
            <param-name>allowScriptlets</param-name>
            <param-value>false</param-value>
        </init-param>
        <init-param>
            <param-name>allowExpressions</param-name>
            <param-value>true</param-value>
        </init-param>
        <init-param>
            <param-name>allowRTExpressions</param-name>
            <param-value>true</param-value>
        </init-param>
    </validator>
</taglib>
scriptfree-bad.jsp, and what Tomcat 11.0.26 says
<%@ taglib prefix="sf" uri="https://example.org/jsp/scriptfree" %>
<% int x = 1; %>
<p>${x}</p>
org.apache.jasper.JasperException:
<h3>Validation error messages from TagLibraryValidator for [scriptfree] in [/WEB-INF/views/scriptfree-bad.jsp]</h3><p>null: JSP page contains 1 scriptlet.</p>

With the library imported, a page with <p>${1 + 1} and <%= 2 + 2 %></p> is accepted (it printed <p>2 and 4</p>), because the descriptor sets allowExpressions to true; the packaged jakarta.tags.scriptfree rejects the same page with null: JSP page contains 1 expression. The null: is the jsp:id of the message, which ScriptFreeTLV does not set.

The second validator is configured in the same way:

WEB-INF/tlds/permitted.tld, the validator element
<validator>
    <validator-class>jakarta.servlet.jsp.jstl.tlv.PermittedTaglibsTLV</validator-class>
    <init-param>
        <param-name>permittedTaglibs</param-name>
        <param-value>
            jakarta.tags.core
            https://example.org/jsp/bookshelf
            https://example.org/jsp/permitted
        </param-value>
    </init-param>
</validator>

A page that imports the validator library and jakarta.tags.fmt fails with taglib p (https://example.org/jsp/permitted) allows only the following taglibs to be imported: [https://example.org/jsp/bookshelf, https://example.org/jsp/permitted, jakarta.tags.core], and a page that imports only jakarta.tags.core and the validator library printed ok. The validator library’s own URI has to be in the list: a page that imports it has imported it.

Two limits matter. These validators are policy for page authors: they stop a careless scriptlet, not a malicious one (a page that a hostile author controls does not need to import the library at all; the control that holds is to forbid scripting by configuration, scripting-invalid in a property group, or by not letting such authors write JSPs, as the configuration page explains). And they see the page through the XML view, so they check what the page says and not what the EL expressions in it will do. jsp-config is also the natural place to require the validator, as the next listing shows.

A property group whose include-prelude file carries the taglib directive imports the validator library into every page the group matches, so the page author cannot forget it. The verification WAR matched one page with an exact pattern:

web.xml (the property group) and WEB-INF/prelude-scriptfree.jspf
<jsp-property-group>
    <url-pattern>/WEB-INF/views/pg-bad.jsp</url-pattern>
    <include-prelude>/WEB-INF/prelude-scriptfree.jspf</include-prelude>
</jsp-property-group>
<%@ taglib prefix="sf" uri="https://example.org/jsp/scriptfree" %>

The page pg-bad.jsp itself contains only a scriptlet and no taglib directive, and Tomcat 11.0.26 rejected it with Validation error messages from TagLibraryValidator for [scriptfree] in [/WEB-INF/views/pg-bad.jsp] and null: JSP page contains 1 scriptlet.

Listeners in a Descriptor

A tag library can ship Servlet event listeners. The listener element holds a listener-class, and the container, which is "required to locate all TLD files, read their listener elements, and treat the event listeners as extensions of those listed in web.xml", instantiates and registers them before the application starts. The order among them is undefined, and resource injection works in them as it does in tag handlers. The Bookshelf listener leaves a marker in the application scope.

bookshelf/TldListener.java
public class TldListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        event.getServletContext().setAttribute("bookshelfTldListener", "started by the tag library");
    }
}
WEB-INF/tlds/bookshelf.tld, the listener element (after validator, before the first tag)
<listener>
    <listener-class>bookshelf.TldListener</listener-class>
</listener>

The page functions.jsp ends with <p>listener: ${applicationScope.bookshelfTldListener}</p> and printed <p>listener: started by the tag library</p>: the listener ran with no entry in web.xml and no annotation. That is useful for a library that needs to initialise itself (a cache, a bundle, a registry), and a supply-chain consideration for everybody else: any JAR dropped in WEB-INF/lib with a descriptor and a listener element runs code at start-up, without being named anywhere in the application’s own configuration. Review a library’s descriptors as well as its classes.

Tool Extensions

The descriptor has three extension points, taglib-extension on the library, tag-extension on a tag or tag file and function-extension on a function, for information that only tools use (the specification’s example is a fictitious company that attaches sound files to tags). The contract protects portability: "JSP containers must not alter their behavior based on the content, the presence, or the absence of a particular Tag or Tag Library Extension Element", and a library whose extension element says mustUnderstand="true" is invalid, so "any attempt to use an invalid tag library must produce a translation error". An extension is declared by importing an XML namespace into the descriptor; nothing about it reaches a running page.

Customising a Library at Deployment

Section 7.5.3 admits that there is "no convenient place in web.xml" for the settings of a tag library (the specification’s example is a database login) and suggests that a library author put them "in a well-known location at some resource in the WEB-INF/ portion of the Web Application" and read them with ServletContext.getResource. That advice is from the JSP 1.x era and predates context-param conventions, @Resource injection and the container’s own configuration. Today a library reads context parameters or JNDI entries (the Standard Tag Library does it with its jakarta.servlet.jsp.jstl.* context parameters, see the internationalisation page), and never stores credentials in a descriptor, in web.xml or in the WAR: they come from the environment or from the container.

Pitfalls and Error Messages

Table 13. What goes wrong with descriptors and functions, and how it shows
Mistake What happens Fix

A descriptor without <uri>

No implicit map entry: The absolute uri: [...] cannot be resolved in either web.xml or the jar files deployed with this application

Add the uri, or map it in web.xml, or reach it by path

The .tld in WEB-INF/classes, loose in WEB-INF/lib, or in WEB-INF/tags

Never found (verified for WEB-INF/classes); the same unresolved-URI error

WEB-INF/tlds or META-INF of a JAR

The library JAR matches a Tomcat skip pattern, or the library JAR is not in WEB-INF/lib

Unresolved URI, with no hint that scanning skipped the JAR

Check tldSkip/tldScan and the jarsToSkip property; enable debug logging of org.apache.jasper.servlet.TldScanner

Two descriptors with the same uri

Tomcat 11.0.26 used the function of the second file (dupe2.tld) and reported nothing; the specification calls two different resource paths for one URI a fatal error, and the search order is unspecified

Give every library a unique URI; look for duplicates when a JAR is vendored twice under different names

A uri element copied between libraries

Same as above, with the wrong behaviour only in some deployments

Use a URI under a domain you control, with the library name and, if you want, a version

An EL expression or scriptlet in an attribute whose rtexprvalue is false (the default)

According to TLD or attribute directive in tag file, attribute [var] does not accept any expressions

Set <rtexprvalue>true</rtexprvalue> when the value is dynamic, and leave it false only for names the container must know at translation time

<rtexprvalue> together with <fragment>, or <fragment> together with a deferred element

Schema-invalid descriptor (the schema allows one group or the other); Tomcat does not check the schema

Pick one

Forgetting <body-content>; the JSP 1.2 default (JSP) is gone since JSP 2.0

Schema-invalid descriptor, and a tag that is empty and gets a body fails with According to TLD, tag [bs:rating] must be empty, but is not

Declare it on every tag: empty or scriptless for simple tags

body-content JSP on a SimpleTag

The TLD for the class [bookshelf.RatingTag] specifies an invalid body-content (JSP) for a SimpleTag.

scriptless or empty

Wrong number of arguments in a function call

ELException: Function [bs:pages] specifies [2] params, but [1] were declared

Match the function-signature

A function-signature whose types do not match any method

Method [nothing] for function [f] not found in class [bookshelf.BookFunctions] (also when only the parameter types are wrong)

Use fully qualified types, no parameter names, and the exact Java types; primitives by name

A function method that is not static

Tomcat 11.0.26: no translation error; NullPointerException: Cannot invoke "Object.getClass()" because "obj" is null when called

Make it public static, in a public class

A function that throws

ELException: Problems calling function [bs:isbn13], with the cause nested

Treat functions as total (return a default) or catch the cause in the page with c:catch

A validator that assumes the tag is present, or that parses the XML view with a default-configured XML parser

Errors on pages that do not use the tag; a parser that resolves doctypes and external entities is an unnecessary risk

Check for the element, and disable doctypes and external entities, as RatingRangeTLV does

Using jsp:id as a line number

4: ... in the message points at the fourth element, not at line 4

Report the element or attribute, and the tag name, in the message text

Heavy work, or request-specific state, in a validator or TagExtraInfo class

They run at translation time, when the page is compiled, not per request

Keep them stateless and fast

<jsp-version> in a TLD, as in the examples of chapter 14 of the Tags specification

Ignored by Tomcat; it is not in the 3.0 or 4.0 schema

Drop it; the schema version attribute is the JSP version

Dynamic-attribute values written to the output without escaping, as the badge example does for the sake of brevity

Cross-site scripting through an attribute name or value

Escape output in the handler (the Java equivalent of fn:escapeXml, or an encoder), and reject names that are not valid attribute names

The taglib directive after the first use of its prefix

The prefix [bs] specified in this tag directive has been previously used by an action in file ...

Put directives at the top

A listener in a descriptor that depends on the order of other listeners

The order is undefined

Make listeners independent

Secrets in a descriptor (an init-param with a password)

They sit in the JAR, in the WAR and in the repository

Read them from the environment or JNDI in the code

What Changed Recently

Table 14. The history of the Tag Library Descriptor and EL functions
Version Change

JSP 1.1 (1999)

Tag extensions with classic handlers; the descriptor is a DTD-based file, a single META-INF/taglib.tld per JAR, and web.xml has a taglib map directly under web-app

JSP 1.2 (2001)

Several descriptors per JAR; the uri element and the implicit URI mapping that make a library JAR drop-in; TagLibraryValidator ("tag libraries that are designed to run in JSP 1.2 containers or higher should use the validator class mechanism"); listeners in descriptors; the descriptor DTD 1.2 (tlibversion, jspversion, tagclass, bodycontent)

JSP 2.0 (2003)

The descriptor becomes an XML Schema; SimpleTag, dynamic-attributes, fragment attributes, tag-file, EL functions and the function element, jsp-config replaces the bare taglib map in web.xml; the default body type disappears

JSP 2.1 (2006)

deferred-value and deferred-method for the unified EL; schema web-jsptaglibrary_2_1.xsd

Jakarta Pages 3.0 (EE 9, 2020)

The package is jakarta.servlet.jsp, and the descriptor namespace is https://jakarta.ee/xml/ns/jakartaee; the XML Schemas and Javadoc "were removed and are now included by reference"

Jakarta Pages 3.1 (EE 10, 2022)

web-jsptaglibrary_3_1.xsd; clarified scope of scripting variables; isThreadSafe and jsp:plugin deprecated

Jakarta Pages 4.0 (EE 11, 2025)

web-jsptaglibrary_4_0.xsd; Java 17 minimum; the deprecated BodyTag.EVAL_BODY_TAG constant, the deprecated classes of jakarta.servlet.jsp.el, JspException.getRootCause, isThreadSafe and jsp:plugin removed. The descriptor model (tag, tag-file, function, validator, listener) is unchanged; the EL 3.0 imports mean that a function without a prefix can no longer be validated at translation time

Jakarta Tags 3.0 (EE 10)

The library URIs are jakarta.tags.* and the validator libraries ship as jakarta.tags.scriptfree and jakarta.tags.permittedTaglibs; the validator classes are in jakarta.servlet.jsp.jstl.tlv

Pages 4.1 (under development for Jakarta EE 12)

Milestone work (the specification page lists "4.1-M1", and the Maven API has a 4.1.0-M2 milestone); no change to the descriptor is part of this page’s baseline

In practice, moving a library from Java EE to Jakarta EE touches three things: the Java imports in handlers, validators and TagExtraInfo classes (javax.servlet.jsp becomes jakarta.servlet.jsp), the descriptor namespace and schema location if the descriptor uses the old ones (Tomcat 11.0.26 accepted a 2.1 descriptor in the old namespace, so the change is for tools and for tidiness), and, for libraries compiled against BodyTag.EVAL_BODY_TAG, the recompilation against EVAL_BODY_BUFFERED. The URIs of the Standard Tag Library moved from http://java.sun.com/jsp/jstl/* to jakarta.tags.*; on Tomcat 11.0.26 with JSTL 3.0.1 the old http://java.sun.com/jsp/jstl/core URI still resolved (legacy-uri printed OK in the verification WAR), because the JSTL JAR still ships the old descriptors. They are identifiers, not links.

Where the Books Are Outdated

  • Web Development with JavaServer Pages (2nd edition, 2001) teaches classic tag handlers and the JSP 1.2 descriptor: a DTD document type declaration, tlibversion, jspversion, shortname, tagclass, bodycontent, and a taglib map directly under web-app in a Servlet 2.3 web.xml. The element names of the 1.2 DTD (tagclass, bodycontent, tlibversion) are read by Tomcat 11.0.26 (a 1.2 descriptor with <tagclass> worked in the verification WAR), but they are the old form; current descriptors use tag-class, body-content and tlib-version, the Jakarta namespace and a schema, and the taglib map belongs in jsp-config. Its rtexprvalue and TagExtraInfo material still applies. Its default of JSP for bodycontent no longer exists.

  • The same book’s chapter on validating tag libraries is current in its mechanics (TagLibraryValidator, PageData, ValidationMessage, jsp:id, descriptors packaged under META-INF) and outdated in its package names: the validator classes live in jakarta.servlet.jsp.tagext now.

  • JSTL in Action (2002) describes JSTL 1.0 and its _rt twin libraries (c_rt, fmt_rt, …​), which exist so that the tags can take Java expressions; since JSP 2.0 the EL works in every request-time attribute and the twins are gone from jakarta.tags.*. It shows custom tags that evaluate EL manually with Jakarta Taglibs' ExpressionEvaluatorManager; today a handler declares the attribute type and the container converts. Its ScriptFreeTLV and PermittedTaglibsTLV chapter is still accurate for the classes; the package is jakarta.servlet.jsp.jstl.tlv and the URIs of its examples are jakarta.tags.*.

  • Both books install libraries with descriptors in WEB-INF and entries in web.xml; since JSP 1.2 the uri element and the implicit map make the entry unnecessary, and the JAR is simply dropped in WEB-INF/lib.

  • Neither book covers tag-file, dynamic-attributes, fragment attributes or EL functions (JSP 2.0 and later), and they predate the deprecation of scripting, so they use scripting variables and variable elements where today’s pages use EL.

  • Tutorials that still write <%@ taglib uri="/WEB-INF/tlds/foo.tld" prefix="foo" %> are using the fallback path rule of section 7.3.6. It works, but a stable uri is the form to use.

Official Documentation Inconsistencies

The specifications' own examples and statements need attention; the discrepancies were found by comparing them with the schema and by running them.

  • Chapter 14 of the Tags 3.0 specification shows descriptors with <jsp-version>2.0</jsp.version> (the end tag does not match the start tag, so the document is not well formed), <short-name>jstl taglibs only</scriptfree> (again a mismatched end tag, and a short name with white space, which the schema forbids), and an element jsp-version that does not exist in the 3.0 or 4.0 schema. Copying them verbatim gives a parse error; the version="3.0" attribute of the root is what states the JSP version. Tomcat ignores an unknown jsp-version element in a well-formed descriptor.

  • The same chapter lists the classes as jakarta.servlet.jsp.jstl.tlv.ScriptFree and ...PermittedTaglibs in the overview and as ScriptFreeTLV and PermittedTaglibsTLV in the examples. The classes are ScriptFreeTLV and PermittedTaglibsTLV.

  • Section 7.3.1 of the Pages 4.0 specification still gives the schema as http://java.sun.com/xml/ns/j2ee/web-jsptaglibrary_2_1.xsd, and section 7.3.10’s example uses the javaee namespace and version="2.1"; the schema of Pages 4.0 is https://jakarta.ee/xml/ns/jakartaee/web-jsptaglibrary_4_0.xsd.

  • Section 7.2.3 says "it is a fatal translation error for a URI attribute value to resolve to two different TLD resource paths". Two .tld files in WEB-INF/tlds with the same uri were accepted by Tomcat 11.0.26 without a message, and the second one won. The order of the search is "implementation-specific and should not be relied on", so the winner is not a feature.

  • Section 2.11.2 says a function method that is not public static is a translation-time error. Tomcat 11.0.26 reported it at request time (see "How a Function Call Is Resolved").

  • The XML Schema comment for the rtexprvalue element speaks of "scriptlet expressions"; the same property controls EL expressions as well (section 1.14.1).

References