JavaRush /Courses /Kotlin SELF /XPath on top of the DOM

XPath on top of the DOM

Kotlin SELF
Level 50, Lesson 2
Available

1. One more way to work with XML

When you first get comfortable with the DOM, you get the feeling: “That’s it, I can reach any node—I’ll just walk the tree.” That’s true… roughly until the first XML that’s a bit more complex than a tutorial example. Then the classics begin: lots of getElementsByTagName, a bunch of item(0) indices, type casts, emptiness checks—and your data-reading code becomes longer than the XML itself.

XPath is useful because it lets you describe the selection declaratively: “give me the user name with id=42,” not “first find all users, then iterate, then compare id, then extract name.”

You can think of XPath as a “mini query language” for an XML tree. It feels similar to navigating folders (paths like /users/user/name), but with filters and “deep search.” And yes—sometimes XPath looks like a spell, but we’ll write spells that are short and understandable; otherwise, magic turns into technical debt.

2. XPath runs on top of the DOM

Before writing XPath, it’s important to clear up a common confusion. XPath is not an “alternative XML parser.” In today’s approach, XPath does not parse text. First, we parse XML into a DOM (getting a Document), and only then do we run XPath expressions against the already-built tree.

So our pipeline looks like this:

flowchart LR
    A["XML (String)"] --> B["DOM parse → Document"]
    B --> C["XPath query → value/nodes"]
    C --> D["Kotlin objects (for example, Expense)"]

The DOM is responsible for turning XML into an in-memory structure. XPath is responsible for convenient selections from that structure.

3. Mini XPath glossary: paths, //, attributes, and text()

XPath looks scary at first because of symbols like /, //, @, []. But if you break down the meaning, there’s fairly human logic there. We won’t dive into axes and advanced features—we only need the everyday subset that’s actually used in application code.

Below is a small cheat-sheet table. You can keep it nearby and not try to memorize it on the first pass (we’re not taking an exam on ancient runes).

XPath fragment Meaning Example
/a/b/c
path from the document root
/users/user/name
//c
find c anywhere in the document (“deep” search)
//item
@id
the id attribute of the current node
/user/@id
[@id='42']
filter (predicate) by attribute
//user[@id='42']
text()
text node (content)
/user/name/text()

Two notes that will save your nerves.

First, // is convenient, but risky: it may find more nodes than you expected, especially if the document is large or the structure changes.

Second, XPath often returns strings containing spaces and line breaks (due to XML formatting), so trim() is your friend, not your enemy.

4. Setup and basic XPath modes in Kotlin

Creating a Document and an XPath

Now we’ll assemble the minimal technical “plumbing”: how to get a Document and how to create an XPath object that can execute queries. This is standard Java API, but Kotlin calls it just fine (by this point we’re no longer afraid of Java interoperability).

A Kotlin main skeleton looks familiar, so we’ll start with a simple example: parse XML → create XPath → extract a value.


import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory

fun main() {
    val xml = """<user id="42"><name>Ann</name></user>"""

    val doc = DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

    val xPath = XPathFactory.newInstance().newXPath()
    val name = xPath.evaluate("/user/name/text()", doc).trim()

    println(name) // Ann
}

Notice: evaluate(...) returned a string here. This is the simplest XPath mode: “give me the result as text.” It’s ideal for pinpoint selections like a name, a single amount, a single status.

“Give me a string” mode: evaluate(expr, doc) and empty results

When you use evaluate(...) without specifying the result type, you usually get a string. There’s an important practical detail: if the node is not found, you often get not null, but an empty string. That’s not a bug; it’s just the contract of that mode.

To avoid mixing “empty value in XML” with “nothing found,” it’s handy to make a small utility: “return a String?.”

import javax.xml.xpath.XPath

fun xPathTextOrNull(xPath: XPath, expr: String, doc: Any): String? {
    val raw = xPath.evaluate(expr, doc).trim()
    return raw.takeIf { it.isNotEmpty() }
}

And usage:

import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory

fun main() {
    val xml = """<user id="42"></user>"""
    val doc = DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

    val xPath = XPathFactory.newInstance().newXPath()
    val name = xPathTextOrNull(xPath, "/user/name/text()", doc)

    println(name) // null
}

Here we intentionally convert an “empty string” into “no value.” This makes the Kotlin logic much more honest: null clearly means “not found,” instead of forcing you to remember that “empty string sometimes means absence.”

“Give me nodes” mode: NODESET and NodeList

When you need to extract not one value but many similar elements (for example, a shopping list, a list of transactions, a list of users), a string is no longer suitable. We need a “node set.”

In the Java XPath API, this is done via XPathConstants.NODESET, and the result will be a NodeList.

Important: NodeList is not a List. You can’t conveniently map{} and filter{} without converting it. But you can iterate by indices: 0 until nodes.length (yes, indices from zero again). That will be the main style we use today.

Mini example:

import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.w3c.dom.NodeList

fun main() {
    val xml = """
        <items>
            <item>Milk</item>
            <item>Bread</item>
        </items>
    """.trimIndent()

    val doc = DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

    val xPath = XPathFactory.newInstance().newXPath()
    val nodes = xPath.evaluate("//item", doc, XPathConstants.NODESET) as NodeList

    for (i in 0 until nodes.length) {
        println(nodes.item(i).textContent.trim())
        // Milk
        // Bread
    }
}

The for loop in Kotlin works on top of an iterator, but NodeList is not Iterable, so we use the good old index-based style.

5. Importing expenses from XML via XPath

Now we’ll do what we set out to do: add the ability to (say) a console app (roughly, an expense tracker) to extract expenses from XML. We won’t complicate dates and currencies: let the amount be a Double, and category and description be strings. We’ll take XML that’s simple but realistic.

Data model

Let’s create a compact Expense model. In a real project it may already be richer—today we care about import.

data class Expense(
    val amount: Double,
    val category: String,
    val note: String
)

The XML we will import

val xml = """
    <expenses>
        <expense category="food">
            <amount>12.50</amount>
            <note>Lunch</note>
        </expense>
        <expense category="transport">
            <amount>2.75</amount>
            <note>Bus</note>
        </expense>
    </expenses>
""".trimIndent()

Parse the string into a Document

Keep the function short: one responsibility—get the DOM.

import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Document

fun parseXml(xml: String): Document =
    DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

Select <expense> nodes via XPath

Here XPath gives us a list of elements <expense ...>...</expense>.

import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.w3c.dom.Document
import org.w3c.dom.NodeList

fun selectExpenseNodes(doc: Document): NodeList {
    val xPath = XPathFactory.newInstance().newXPath()
    return xPath.evaluate("/expenses/expense", doc, XPathConstants.NODESET) as NodeList
}

Convert NodeList to List<Expense>

This is where the “bridge” between the XML world and the Kotlin world begins.

Notice: we read the category attribute and the text of amount/note. For amount, we use a careful toDoubleOrNull()—because data can be malformed, and our code shouldn’t crash due to one weird character.

import org.w3c.dom.Element
import org.w3c.dom.NodeList

fun parseExpenses(nodes: NodeList): List<Expense> {
    val result = mutableListOf<Expense>()

    for (i in 0 until nodes.length) {
        val el = nodes.item(i) as Element
        val category = el.getAttribute("category").trim()
        val amount = el.getElementsByTagName("amount")
            .item(0).textContent.trim().toDoubleOrNull() ?: 0.0
        val note = el.getElementsByTagName("note")
            .item(0).textContent.trim()

        result.add(Expense(amount, category, note))
    }

    return result
}

Yes, there are still DOM methods (getElementsByTagName) here—and that’s fine. XPath doesn’t have to replace absolutely everything. Our practical goal is: XPath helps you quickly find the right “points of interest” (for example, all expense nodes), and then you can read nested fields in whatever way is clearest to you.

Put it together in main and print the result

fun main() {
    val doc = parseXml(xml)
    val nodes = selectExpenseNodes(doc)
    val expenses = parseExpenses(nodes)

    for (e in expenses) {
        println("${e.category}: ${e.amount} — ${e.note}")
        // food: 12.5 — Lunch
        // transport: 2.75 — Bus
    }
}

Even now, this looks like a real task: “I have some XML from somewhere (file, network, integration); get me a list of entities.”

6. Filters and XPath readability in a project

Filtering: predicates [...]

In practice, people like XPath not only for the “short path,” but also for filters. A filter is square brackets that refine a selection. The most common case is filtering by an attribute.

Say we only need expenses in the food category. The XPath expression would be:

/expenses/expense[@category='food']

Let’s show it directly in code, minimally:

import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.w3c.dom.NodeList

fun selectFoodExpenses(doc: Any): NodeList {
    val xPath = XPathFactory.newInstance().newXPath()
    val expr = "/expenses/expense[@category='food']"
    return xPath.evaluate(expr, doc, XPathConstants.NODESET) as NodeList
}

And now an important “grown-up” thought about maintainability: filters are better kept short. If you start writing XPath that looks like SQL on steroids, it’s usually a sign you’re trying to cram business logic into XPath. XPath is great as a selection language, but “product rules” are better kept in Kotlin code, where they’re easier to test and read.

How to keep XPath readable

Once XPath appears in a codebase, over time it tends to multiply. And if you treat it without discipline, you’ll get “spell strings” without names and without explanations. That’s the kind of code that works… but nobody wants to touch it.

A simple technique usually helps: pull expressions out into val constants with a meaningful name, rather than keeping them directly inside evaluate.

const val XP_EXPENSES = "/expenses/expense"
const val XP_FOOD_EXPENSES = "/expenses/expense[@category='food']"
const val XP_FIRST_NOTE = "(/expenses/expense/note/text())[1]"

And then:

val nodes = xPath.evaluate(XP_EXPENSES, doc, XPathConstants.NODESET) as NodeList

One more cautious tip: use // only when you truly mean “search everywhere.” If the document structure is known, an explicit path like /expenses/expense is usually more reliable and predictable than //expense.

7. Common mistakes when using XPath on top of the DOM

Mistake #1: expecting XPath to return null when nothing is found.
In string mode, evaluate(...) most often returns an empty string rather than null. If you then call toInt() or toDouble() without checks, you’ll get an exception even on “emptiness.” It’s more convenient to create a utility like xPathTextOrNull(...): String? and honestly distinguish “not found” from “found but empty.”

Mistake #2: overusing // and getting “too many” nodes.
//item looks convenient while the document is small. But if the XML grows or nested item nodes appear elsewhere, you’ll suddenly start importing extra entities. This is one of the nastiest bugs: the code doesn’t crash; it just does the wrong thing. It’s usually better to start with an exact path from the root.

Mistake #3: forgetting about whitespace and line breaks in textContent.
Pretty-printed XML contains indentation and line breaks, and that’s also text. That’s why textContent and string-based evaluate(...) often return values with “invisible” spaces. If you don’t do trim(), you can catch weird bugs like “the category looks like food, but for some reason it doesn’t match.” The fix is disciplined string normalization.

Mistake #4: mixing up result modes and then crashing on casts.
If you did evaluate(..., NODESET)—expect a NodeList. If you did just evaluate(expr, doc)—expect a string. When those get mixed, you get a ClassCastException at the most unexpected time. It helps a lot to split into two utilities: selectNodes(...) and selectText(...), so the contract is visible from the name.

Mistake #5: calling item(0) without checking that a node exists at all.
A NodeList can be empty. If you immediately do item(0) and cast to Element, you risk a NullPointerException or a cast error. In import code, it’s useful to check nodes.length first; and if exactly one node is expected, handle the “no data” situation honestly (message, null, exception—depending on the function contract).

Mistake #6: making XPath too clever and hiding business logic in a string.
When the expression inside square brackets turns into a “mini program,” maintainability drops sharply: it’s hard to debug, hard to read, hard to change. XPath is an excellent knife for slicing, but a bad kitchen for cooking the entire dish. Usually it’s more reasonable to select nodes with XPath, then filter and validate in Kotlin, where you control types and errors.

1
Task
Kotlin SELF, level 50, lesson 2
Locked
Name via the Path
Name via the Path
1
Task
Kotlin SELF, level 50, lesson 2
Locked
Pass ID
Pass ID
1
Task
Kotlin SELF, level 50, lesson 2
Locked
Shopping List
Shopping List
1
Task
Kotlin SELF, level 50, lesson 2
Locked
Filter by category
Filter by category
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION