Properties
Fields in a model schema are plain Zod types by default (child elements), xml.attr() for XML attributes, or xml.prop() when you need to customise a child element. All helpers attach XML metadata to the Zod schema via Zod v4's .meta() API.
Child elements
Every field that is not annotated with xml.attr() is encoded as a child element. The field name is converted to kebab-case for the tag name by default — no annotation required.
z.object({
title: z.string(), // <title>…</title>
publishedAt: z.number(), // <published-at>…</published-at>
});Use xml.prop(schema, options) only when you need to customise the element — see options below.
xml.prop() options
| Option | Type | Description |
|---|---|---|
tagname | string | Override the element tag name. |
inline | boolean | For arrays: place items as direct siblings instead of inside a wrapper element. See Arrays. |
match | RegExp | (el) => boolean | Custom predicate for matching source elements during parsing. |
decode | (ctx, next) => void | Custom decoding hook. Call next() to run the default decode logic. See Custom decode/encode. |
encode | (ctx, next) => void | Custom encoding hook. Call next() to run the default encode logic. See Custom decode/encode. |
When you call xml.prop(options) with no schema argument, it returns a Zod GlobalMeta object. This lets you attach the annotation with Zod's own .meta() — both forms are equivalent:
// these two are identical
xml.prop(z.string(), { tagname: "pub-date" });
z.string().meta(xml.prop({ tagname: "pub-date" }));The same applies to xml.attr() and xml.root().
XML attributes — xml.attr()
xml.attr(schema, options?) marks a field as an XML attribute on the root element.
The attribute name defaults to the field key in kebab-case — the same conversion applied to child element tag names. Pass { name } only when the desired attribute name differs from that default.
z.object({
vin: xml.attr(z.string()), // attribute "vin" (field key already kebab-case)
vehicleId: xml.attr(z.string()), // attribute "vehicle-id" (auto kebab-case)
id: xml.attr(z.string(), { name: "ID" }), // attribute "ID" (custom name required)
});/**
* Base vehicle class. Extends `XMLBase` so all vehicle subclasses inherit
* round-trip preservation — element order and unknown extensions are kept
* intact without any per-subclass ceremony.
* Demonstrates `xml.attr()` for identifier fields and custom instance methods.
*/
export class Vehicle extends XMLBase.extend(
{
/** Unique identifier stored as a root XML attribute: `<vehicle vin="...">` */
vin: xml.attr(z.string()),
/** Manufacturer name stored as a child element: `<make>Toyota</make>` */
make: z.string(),
/** Production year stored as a child element: `<year>2020</year>` */
year: z.number(),
},
xml.root({ tagname: "vehicle" }),
) {
/** Returns a human-readable label for this vehicle. */
label() {
return `${this.year} ${this.make}`;
}
}<vehicle vin="V001"><make>Toyota</make><year>2020</year></vehicle>vin is an attribute; make and year are child elements.
Nested models
Pass an xmlModel class directly to xml.prop() to embed it as a child element. The codec parses it into a class instance automatically.
/**
* A car engine. Extends `XMLBase` so unknown vendor elements inside `<engine>`
* (e.g. manufacturer extensions) survive a read-modify-write cycle.
* Demonstrates a basic nested class with one XML attribute (`type`) and one
* child element (`horsepower`).
*/
export class Engine extends XMLBase.extend(
{
/** Fuel type stored as an XML attribute: `<engine type="petrol">` */
type: xml.attr(z.string()),
/** Power output stored as a child element: `<horsepower>150</horsepower>` */
horsepower: z.number(),
},
xml.root({ tagname: "engine" }),
) {}/**
* Car extends Vehicle using `Vehicle.extend()`, which creates a **true subclass**:
* instances are `instanceof Vehicle` and inherit Vehicle's methods (e.g. `label()`).
*
* Demonstrates:
* - chaining `.extend()` to add fields to a parent model
* - `xml.prop(Engine)` to embed a nested xmlModel class as a child element
*/
export class Car extends Vehicle.extend(
{
/** Number of doors: `<doors>4</doors>` */
doors: z.number(),
/**
* Nested engine. Passing an xmlModel class to `xml.prop()` embeds it as a
* child element and parses it into the correct class instance automatically.
*/
engine: Engine.schema(),
},
xml.root({ tagname: "car" }),
) {}const car = Car.fromXML(`
<car vin="V001">
<make>Toyota</make><year>2020</year><doors>4</doors>
<engine type="petrol"><horsepower>150</horsepower></engine>
</car>
`);
car.engine instanceof Engine; // true
car.engine.horsepower; // 150When using a class as a field value inside z.array(), use MyClass.schema() instead — it returns a ZodPipe that also instantiates the class:
cars: xml.prop(z.array(Car.schema()), { inline: true }),Optional fields
Wrap the schema in z.optional() to make a field optional. When the element is absent from the XML the field is undefined; toXMLString omits it entirely.
/**
* Motorcycle extends Vehicle with an optional boolean field.
* When `<sidecar>` is absent from the XML the field is `undefined`;
* `toXMLString` omits it entirely.
*/
export class Motorcycle extends Vehicle.extend(
{
/** Whether a sidecar is attached. Omitted from XML when `undefined`. */
sidecar: z.boolean().optional(),
},
xml.root({ tagname: "motorcycle" }),
) {}const moto = Motorcycle.fromXML(
`<motorcycle vin="V003"><make>Kawasaki</make><year>2019</year></motorcycle>`,
);
moto.sidecar; // undefined
const motoWithSidecar = Motorcycle.fromXML(`
<motorcycle vin="V005"><make>Ural</make><year>2018</year><sidecar>true</sidecar></motorcycle>
`);
motoWithSidecar.sidecar; // trueArrays
Inline arrays (inline: true)
Each item is a direct child of the root element. Items of different types can be interleaved freely in document order.
/**
* Fleet demonstrates **inline arrays** of multiple vehicle types.
* `inline: true` places each item as a direct sibling element inside the
* root tag rather than wrapping them in a container element.
*/
export class Fleet extends XMLBase.extend(
{
/** Fleet name stored as a root XML attribute: `<fleet name="...">` */
name: xml.attr(z.string()),
/**
* Inline list of cars. Each `<car>` is a direct child of `<fleet>`.
* `Car.schema()` returns a ZodPipe that also instantiates `Car` objects.
*/
cars: xml.prop(z.array(Car.schema()), {
inline: true,
// FIXME: should be required for inline arrays
tagname: "car",
}),
/** Inline list of motorcycles. Each `<motorcycle>` is a direct child of `<fleet>`. */
motorcycles: xml.prop(z.array(Motorcycle.schema()), {
inline: true,
// FIXME: should be required for inline arrays
tagname: "motorcycle",
}),
},
xml.root({ tagname: "fleet" }),
) {
/** Total number of vehicles across all types in this fleet. */
totalVehicles() {
return this.cars.length + this.motorcycles.length;
}
}<fleet name="Acme Fleet">
<car vin="V001">…</car>
<car vin="V002">…</car>
<motorcycle vin="V003">…</motorcycle>
</fleet>Each <car> and <motorcycle> is a direct child of <fleet>. The codec matches them by their root tag name.
Non-inline arrays (default)
Items are nested inside a single wrapper element whose tag name comes from the field name (kebab-cased).
/**
* A showroom holds an inventory of car model names.
* Demonstrates a **non-inline** (wrapped) array: all items are nested inside
* a single `<models>` container element, as opposed to being direct siblings
* of the root element.
*
* ```xml
* <showroom name="Acme Dealers">
* <models>
* <model>Corolla</model>
* <model>Civic</model>
* </models>
* </showroom>
* ```
*
* Contrast with `Fleet`, which uses `inline: true` so each `<car>` / `<motorcycle>`
* is a direct child of `<fleet>` with no container wrapper.
*/
export class Showroom extends XMLBase.extend(
{
/** Showroom name stored as a root XML attribute: `<showroom name="...">` */
name: xml.attr(z.string()),
/**
* Inventory of model names. Without `inline: true` the codec expects items
* wrapped inside a single `<models>` container element; the tag name of each
* individual item is not significant during parsing.
*/
models: z.array(xml.root(z.string(), { tagname: "model" })),
},
xml.root({ tagname: "showroom" }),
) {}<showroom name="Acme Dealers">
<models>
<model>Corolla</model>
<model>Civic</model>
<model>Mustang</model>
</models>
</showroom>All <model> items live inside the <models> container. The tag name of individual items is not significant during parsing.
When to use each
Inline (inline: true) | Non-inline (default) | |
|---|---|---|
| Item placement | Direct children of root element | Nested inside a wrapper element |
| Multiple types | Yes — mix freely by tag name | No — single homogeneous list |
| Typical use | Heterogeneous sibling elements | Homogeneous list with a named container |
Discriminated unions
Use z.discriminatedUnion when a field (or inline array) can hold one of several model variants, each identified by a shared discriminator attribute or element.
/**
* A petrol engine, discriminated by `type="petrol"`.
*/
export class PetrolEngine extends XMLBase.extend(
{
type: xml.attr(z.literal("petrol")),
horsepower: z.number(),
},
xml.root({ tagname: "engine" }),
) {}
/**
* An electric engine, discriminated by `type="electric"`.
*/
export class ElectricEngine extends XMLBase.extend(
{
type: xml.attr(z.literal("electric")),
range: z.number(),
},
xml.root({ tagname: "engine" }),
) {}
/**
* Fallback for unrecognised engine types. Unknown child elements are preserved
* through round-trips via XMLBase's state tracking.
*/
export class UnknownEngine extends XMLBase.extend(
{ type: xml.attr(z.string()) },
xml.root({ tagname: "engine" }),
) {}
/**
* A union that matches known engine types by discriminator and falls back to
* `UnknownEngine` for any unrecognised `type` value.
*/
export const AnyEngine = z.union([
z.discriminatedUnion("type", [PetrolEngine.schema(), ElectricEngine.schema()]),
UnknownEngine.schema(),
]);const petrol = AnyEngine.decode({ type: "petrol", horsepower: 150 });
petrol instanceof PetrolEngine; // true
const hybrid = AnyEngine.decode({ type: "hybrid", horsepower: 100 });
hybrid instanceof UnknownEngine; // true — fallback variantThe outer z.union wraps the discriminated union with a fallback variant (UnknownEngine) that catches any unrecognised type value. Omit the outer union if all variants are known and unknown values should be an error.
z.discriminatedUnion dispatches in O(1) by reading the discriminator attribute before decoding the full element, so it is more efficient than a plain z.union when the variant count is large.
When to use each
z.discriminatedUnion | z.union | |
|---|---|---|
| Dispatch | O(1) — reads discriminator first | O(n) — tries each variant in order |
| Requires shared key | Yes — all variants must share a discriminator field | No |
| Unknown-value fallback | Wrap in an outer z.union | Add a catch-all variant last |
Type transforms — z.codec
Use z.codec(inputSchema, outputSchema, { decode, encode }) when a field should be stored as one type in XML but exposed as a different type in your model.
A common use-case is ISO 8601 dates: the XML element contains a plain string, but the parsed instance holds a native Date.
/** ISO 8601 date string ↔ `Date` codec. */
const isoDate = z.codec(z.string(), z.date(), {
decode: (iso) => new Date(iso),
encode: (date) => date.toISOString(),
});
/**
* An event with a typed `Date` field stored as an ISO 8601 string in XML.
* Extends `XMLBase` so element order and unknown elements are preserved across round-trips.
* Demonstrates using `z.codec` to transform a raw XML string into a native JS type.
*/
export class Event extends XMLBase.extend(
{
/** Event title: `<title>…</title>` */
title: z.string(),
/**
* Publication date, stored as an ISO 8601 string in XML but exposed as a
* native `Date` on the parsed instance.
* `<published-at>2024-01-15T00:00:00.000Z</published-at>`
*/
publishedAt: isoDate,
},
xml.root({ tagname: "event" }),
) {}const event = Event.fromXML(`
<event>
<title>Launch</title>
<published-at>2024-01-15T00:00:00.000Z</published-at>
</event>
`);
event.publishedAt; // Date instance — 2024-01-15T00:00:00.000Z
Event.toXMLString(event);
// <event><title>Launch</title><published-at>2024-01-15T00:00:00.000Z</published-at></event>decode receives the raw XML-decoded value (a string here) and returns the transformed value. encode receives the transformed value and returns the raw form that goes back into XML. The two are inverses of each other.
Note:
z.codectransforms are applied by Zod's parse pipeline insidefromXML/toXMLString— you do not need to callschema.parse()yourself. See Parsing pipeline for details.
Custom decode/encode
xml.prop() and xml.root() accept decode and encode hooks that let you intercept and augment the default codec behavior without replacing it entirely.
Property-level (xml.prop)
decode is a void side-effect — mutate ctx.result directly:
xml.prop(z.string(), {
decode(ctx, next) {
next(); // assigns the default-decoded value to ctx.result[fieldName]
(ctx.result as any).title = (ctx.result as any).title?.toUpperCase();
},
});ctx for decode is a PropertyDecodingContext with:
ctx.result— the partially-built parent object (mutate to add/override fields)ctx.property— metadata about the field (name, tagname, options, source XML element)ctx.xml— the source XML element for the parent
encode is a void side-effect — mutate ctx.result directly:
xml.prop(z.string(), {
encode(ctx, next) {
next(); // pushes the default-encoded element to ctx.result.elements
ctx.result.attributes["data-custom"] = "1";
},
});ctx for encode is a PropertyEncodingContext with:
ctx.result— the partially-built parentXMLElement(mutatectx.result.elementsorctx.result.attributes)ctx.property— metadata about the field (name, tagname, options, value)
Root-level (xml.root)
Root-level hooks return the decoded/encoded value, so next() is a factory:
xml.root(MySchema, {
decode(ctx, next) {
const obj = next(); // returns the default-decoded object
return { ...obj, _source: "xml" };
},
encode(ctx, next) {
const el = next(); // returns the default-encoded XMLElement
el.attributes ??= {};
el.attributes["version"] = "1";
return el;
},
});Ordering with optional/default wrappers
When you combine xml.prop(...).optional() (or .default(...)), the wrapper applies before your custom hooks:
ZodOptional: the absent-element null check runs first; if the element is absent the field is set toundefinedwithout calling your hooks.ZodDefault: the default value is applied first; your hooks only run when the element is present.
This means next() inside a decode hook always receives a fully resolved value, never undefined.
// safe: next() is only called when <slug> is present
slug: xml.prop(z.string(), {
decode(ctx, next) {
next();
(ctx.result as any).slug = (ctx.result as any).slug?.toLowerCase();
},
}).optional(),