Skip to content

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.

ts
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

OptionTypeDescription
tagnamestringOverride the element tag name.
inlinebooleanFor arrays: place items as direct siblings instead of inside a wrapper element. See Arrays.
matchRegExp | (el) => booleanCustom predicate for matching source elements during parsing.
decode(ctx, next) => voidCustom decoding hook. Call next() to run the default decode logic. See Custom decode/encode.
encode(ctx, next) => voidCustom 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:

ts
// 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.

ts
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)
});
ts
/**
 * 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}`;
  }
}
xml
<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.

ts
/**
 * 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" }),
) {}
ts
/**
 * 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" }),
) {}
ts
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; // 150

When using a class as a field value inside z.array(), use MyClass.schema() instead — it returns a ZodPipe that also instantiates the class:

ts
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.

ts
/**
 * 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" }),
) {}
ts
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; // true

Arrays

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.

ts
/**
 * 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;
  }
}
xml
<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).

ts
/**
 * 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" }),
) {}
xml
<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 placementDirect children of root elementNested inside a wrapper element
Multiple typesYes — mix freely by tag nameNo — single homogeneous list
Typical useHeterogeneous sibling elementsHomogeneous 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.

ts
/**
 * 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(),
]);
ts
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 variant

The 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.discriminatedUnionz.union
DispatchO(1) — reads discriminator firstO(n) — tries each variant in order
Requires shared keyYes — all variants must share a discriminator fieldNo
Unknown-value fallbackWrap in an outer z.unionAdd 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.

ts
/** 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" }),
) {}
ts
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.codec transforms are applied by Zod's parse pipeline inside fromXML/toXMLString — you do not need to call schema.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:

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

ts
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 parent XMLElement (mutate ctx.result.elements or ctx.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:

ts
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 to undefined without 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.

ts
// 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(),