xmldoc
xmldoc turns an XML string into a small typed tree of `XmlDocument`, `XmlElement`, text, CDATA, and comment nodes. It sits between raw SAX events and a browser-style DOM: parsing is synchronous and strict, attributes become a string dictionary, child elements have convenience searches, and simple dot paths can retrieve known nested values. It is best for predictable XML responses whose structure your code already knows. It is not XPath, a standards-complete DOM, an HTML parser, a streaming interface, or a production XML serializer.
xmldoc is a pleasant small-tree parser for known XML on current ESM runtimes, with better types and maintenance than its age suggests. Do not install it for CommonJS, XPath, namespace-aware querying, huge feeds, HTML scraping, or XML generation.
Use it if
- You consume small or medium XML API responses with a known shape and want direct child and path helpers
- You want strict XML parsing with source line, column, and character-position metadata on elements
- Your project is ESM on Node 22 or newer and benefits from first-party TypeScript declarations
- You need text, CDATA, comments, attributes, and element order preserved in a simple inspectable tree
- You still use CommonJS or Node before 22; the v3 changelog says `require('xmldoc')` is unsupported and sets Node 22 as the minimum, so those projects must remain on 2.x
- You need XPath, CSS selectors, schema validation, namespace-URI resolution, or a standards-style DOM; the README says paths are a custom dot notation and namespace prefixes remain part of literal element names
- You parse very large or unbounded XML; the constructor accepts a complete string and builds a complete in-memory hierarchy rather than exposing SAX events or backpressure
- You need to rewrite and persist XML safely; the README says `toString()` is for debugging and is not guaranteed to always output valid XML
- You are scraping HTML from the web; the README explicitly says the known-structure API is not good at teasing information out of HTML documents
Setup reality
Version 3 setup is easy only if the host is already modern: run `npm install xmldoc`, use `import { XmlDocument } from 'xmldoc'`, and pass a complete XML string to the constructor. The package is pure JavaScript with one runtime dependency, `sax` 1.6.x, and ships its own declarations, so there is no native compilation or type package. The hard boundaries are deliberate. Node 22 is the declared minimum, v3 is ESM-only, and the old browser-global build was removed. A CommonJS application must either migrate, use a dynamic import boundary, or pin the maintained API it knows to the 2.x line. React Native may require explicit `buffer` and `stream` installs according to the README. Parsing is synchronous and strict; an empty or malformed document throws during construction, so surround untrusted input with `try/catch` and apply your own byte or character limit before parsing. The complete tree stays in memory. Namespaces are not resolved: `<office:body>` is named `office:body`, and namespace declaration attributes stay in `attr`. Direct-child helpers do not recurse, dot paths select only the first matching child at each step, and the path language has none of XPath's predicates or namespace rules. `children` contains element, text, CDATA, and comment nodes, while `childNamed`, `childrenNamed`, and `eachChild` filter to elements. An element's `val` contains only its own text and CDATA, not the concatenated text of descendants. Finally, do not use `toString()` as a round-trip writer. It trims surrounding text by default, supports an HTML formatting mode, and is explicitly labeled debug-only. Use a real XML builder when output correctness is part of the contract.
Patterns
Parse a document in ESMparse-document
import { XmlDocument } from 'xmldoc';
const xml = '<catalog version="2"><book id="b1">XML Basics</book></catalog>';
const document = new XmlDocument(xml);
console.log(document.name); // catalog
console.log(document.attr.version); // 2
console.log(document.childNamed('book')?.val); // XML Basicsxmldoc 3 is ESM-only and declares Node 22 or newer. CommonJS `require()` callers should not upgrade from 2.x without a module migration.
Catch invalid or empty XMLhandle-parse-errors
import { XmlDocument } from 'xmldoc';
function tryParseXml(input) {
if (input.length > 1_000_000) return { ok: false, error: 'too large' };
try {
return { ok: true, document: new XmlDocument(input) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
console.log(tryParseXml('<root><broken></root>'));The constructor parses synchronously and throws on malformed XML. The sample size cap is an application policy, not a limit supplied by xmldoc.
Find the first direct child by namefind-direct-child
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<order>
<customer><name>Ada</name></customer>
<status>paid</status>
</order>
`);
const status = doc.childNamed('status');
if (!status) throw new Error('missing status');
console.log(status.val);`childNamed()` checks direct children only and returns `undefined` when absent. It will not find the nested `name` element.
Collect repeated direct elementsfind-repeated-children
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<items>
<item sku="A">10</item>
<item sku="B">20</item>
</items>
`);
const items = doc.childrenNamed('item').map((item) => ({
sku: item.attr.sku,
quantity: Number(item.val),
}));
console.log(items);Attributes and text values are strings. Convert numbers, dates, and booleans explicitly and validate the result.
Find a child with an attributefind-by-attribute
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<users>
<user id="42" role="admin">Ada</user>
<user id="43">Lin</user>
</users>
`);
const exact = doc.childWithAttribute('id', '42');
const hasRole = doc.childWithAttribute('role');
console.log(exact?.val, hasRole?.attr.role);This searches only direct child elements and returns the first match. Omit the value to test for a truthy attribute value.
Read a nested value or attribute with a pathread-known-path
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<book><author><name id="a1">Ursula Le Guin</name></author></book>
`);
console.log(doc.valueWithPath('author.name')); // Ursula Le Guin
console.log(doc.valueWithPath('author.name@id')); // a1
console.log(doc.descendantWithPath('author.name')?.name); // nameDot paths are a small xmldoc convention, not XPath. They select the first matching child at each level and support no predicates or array indexes.
Recursively find descendants by literal namesearch-descendants
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<feed><section><entry>A</entry></section><entry>B</entry></feed>
`);
const entries = doc.descendantsNamed('entry');
console.log(entries.map((entry) => entry.val)); // ['A', 'B']Recursive search walks the in-memory tree and matches literal names. For many complex searches or very large inputs, use a selector-aware or streaming parser.
Address prefixed names literallyhandle-namespaces
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(`
<feed xmlns:atom="http://www.w3.org/2005/Atom">
<atom:entry atom:id="7">Hello</atom:entry>
</feed>
`);
const entry = doc.childNamed('atom:entry');
console.log(entry?.attr['atom:id']); // 7
console.log(doc.attr['xmlns:atom']);xmldoc does not resolve namespace URIs. Prefixes remain in element and attribute names, so documents that choose a different prefix need application handling.
Distinguish elements, text, CDATA, and commentsinspect-mixed-content
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument(
'<root>before<em>inside</em><![CDATA[raw <text>]]><!-- note --></root>',
);
for (const node of doc.children) {
if (node.type === 'element') console.log('element', node.name);
if (node.type === 'text') console.log('text', node.text);
if (node.type === 'cdata') console.log('cdata', node.cdata);
if (node.type === 'comment') console.log('comment', node.comment);
}`children` includes every node type. Element-only helpers filter them out, and the parent `.val` contains only direct text and CDATA, not descendant element text.
Iterate direct child elements with early exititerate-elements
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument('<root>text<a/><b stop="yes"/><c/></root>');
doc.eachChild((child, index) => {
console.log(index, child.name);
if (child.attr.stop === 'yes') return false;
});Returning `false` stops iteration. The callback index refers to the full `children` array, so text or comment nodes can make element indexes non-contiguous.
Attach source positions to validation errorsreport-source-position
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument('<root>
<item id=""/>
</root>');
const item = doc.childNamed('item');
if (item && !item.attr.id) {
throw new Error(`item id is empty near line ${item.line}, column ${item.column}`);
}Elements expose line, column, character position, and start-tag position from the SAX parser. Confirm whether your user-facing line numbering should add one.
Format a parsed subtree for logsformat-for-debugging
import { XmlDocument } from 'xmldoc';
const doc = new XmlDocument('<root><message> a long message for diagnostics only </message></root>');
console.log(doc.toString());
console.log(doc.toString({ compressed: true, preserveWhitespace: true }));
console.log(doc.toString({ trimmed: true }));The README says `toString()` is for debugging and does not guarantee valid XML. It trims surrounding text unless `preserveWhitespace` is set, so do not use it as a persistence serializer.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fast-xml-parser | npm | Choose it for configurable XML-to-object parsing, validation, and XML building without a native dependency |
| xml2js | npm | Choose it when an established callback or Promise-based XML-to-plain-object mapping fits existing CommonJS code |
| saxes | npm | Choose it for event-driven parsing, namespace awareness, and control over memory instead of an eagerly built document tree |