npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xsd-to-zod

v0.3.1

Published

Turn XSD schemas into type-safe Zod parsers for XML, with a metadata-driven runtime for parseXml/serializeXml round-trips.

Readme

xsd-to-zod

npm version npm downloads Tests Node.js >= 22.12 License: GPL-3.0

Turn XSD schemas into type-safe Zod parsers for XML.

xsd-to-zod reads your XSD files and emits strongly-typed Zod schemas that carry their XML knowledge in a typed Zod registry — one generated artifact. Its runtime walks those schemas to parseXml(xml) into plain objects and serializeXml(data) back out again, with validation enforced by the schemas themselves. An optional libxml2-backed conformance tier covers full XSD semantics.

XSD files ──► parseXsd() ──► IR ──► irToZod()
                                        │
                                        ▼
            one .zod.ts: Zod schemas + xmlRegistry entries
                                        │
                                        ▼
            parseXml / safeParseXml / serializeXml   (zod tier)
            validateXml                              (libxml2 tier, optional)

Quick look: XSD → Zod → typed data

Given this order.xsd:

<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
           targetNamespace="urn:example"
           xmlns="urn:example"
           elementFormDefault="qualified">
  <xs:element name="order" type="OrderType" />
  <xs:complexType name="OrderType">
    <xs:sequence>
      <xs:element name="item" type="xs:string" maxOccurs="unbounded" />
      <xs:element name="sku"  type="xs:string" />
    </xs:sequence>
    <xs:attribute name="id" type="xs:int" use="required" />
  </xs:complexType>
</xs:schema>

Generate the code:

npx xsd-to-zod order.xsd -o src/generated --format

The generated order.zod.ts looks like:

import { z } from 'zod';
import { xmlRegistry } from 'xsd-to-zod';

export interface OrderType {
  item: string[];
  sku: string;
  "@id": number;
}
const OrderTypeSchema: z.ZodType<OrderType> = z.lazy(() => z.object({
  "item": z.array(z.string()),
  "sku": z.string(),
  "@id": z.number().int(),
})).register(xmlRegistry, {
  qname: "{urn:example}OrderType",
  fields: {
    item: { kind: "element", qname: "{urn:example}item" },
    sku:  { kind: "element", qname: "{urn:example}sku" },
    "@id": { kind: "attribute", qname: "id" },
  },
});
export const orderSchema = z.lazy(() => OrderTypeSchema)
  .register(xmlRegistry, { root: "{urn:example}order" });

Every complex type becomes an exported interface plus a schema const annotated z.ZodType<Interface>, so z.infer<typeof orderSchema> is OrderType — full compile-time types, including for mutually recursive XSD types.

Use it in TypeScript:

import { parseXml, serializeXml } from 'xsd-to-zod';
import { orderSchema } from './generated/order.zod.js';

const data = parseXml(orderSchema, `
  <order xmlns="urn:example" id="42">
    <item>widget</item>
    <sku>W-001</sku>
  </order>
`);
// data is fully typed: { item: string[], sku: string, '@id': number }

const xml = serializeXml(orderSchema, data);

parseXml throws a ZodError on validation failure — validation is enforced by construction, not by remembering to call .parse(). Use safeParseXml(orderSchema, xml) for a { success, data | error } result object instead.

Features

  • XSD constructs: sequence, choice (→ runtime-enforced from registry metadata), all, attribute, simpleContent, complexContent (extension flattening), xs:group, xs:attributeGroup, xs:redefine, substitution groups (a head element's field becomes a union over its member elements), mixed content (mixed="true" → optional _text field next to the child elements)
  • Simple type restrictions: facets become Zod checks where Zod can express them — enumeration (→ z.enum / literal unions), pattern (→ .regex), length/min/max (→ .length/.min/.max), order facets on xs:decimal (→ exact lexical comparison via xsdDecimalCompare — boundary digits beyond double precision are not rounded), totalDigits/fractionDigits (→ digit-count refinements), whiteSpace collapse/replace (→ preprocess transform). xs:list (→ whitespace-splitting z.preprocess + z.array) and xs:union (→ z.union) are supported
  • Namespaces: Clark notation {ns}local throughout, qualified/unqualified form defaults, xs:include/xs:import across files
  • Chameleon includes: inherited target namespace for includee schemas without a targetNamespace
  • CLI: directory input (recursive .xsd discovery), --include-libraries (auto-skip type-definition-only schemas), --allow-missing-imports (suppress unresolved ref warnings), --silent, and bundle subcommand for merging imports into one self-contained XSD
  • Encoding detection: BOM and declaration sniffing (UTF-16LE/BE, CP1252, UTF-8) via iconv-lite
  • Cardinality: minOccurs/maxOccurs.optional() / z.array() with .min()/.max() bounds; defaults/fixed with XSD-correct semantics (attribute defaults on absence, element defaults on present-but-empty)
  • Nillable: xsi:nil="true".nullable() in schema, round-trips through serializeXml
  • Derived-type polymorphism: a slot whose declared complex type is abstract or has derived types (extension/restriction) in the schema set becomes a discriminated union over the base and derived variants, keyed on a synthetic xsiType property — parseXml dispatches on the instance's xsi:type attribute (derived fields are kept, TS narrows on the discriminant) and serializeXml re-emits it. An xsi:type naming a type outside the generated set parses as the declared type, with the extra content captured for lossless re-serialization (untyped)
  • Cyclic references: every emitted complex-type schema is wrapped in z.lazy(() => ...) so forward references and true cycles (e.g. Person.manager: Person) load without ReferenceError
  • Two validation tiers: the zod tier (typed parse, user-friendly ZodErrors) and an optional libxml2 conformance tier (full XSD semantics, line-numbered errors)
  • Builtin datatype lexicals: the zod tier validates the XSD 1.0 lexical space of the date/time set, duration, hexBinary/base64Binary, language, and the Name/NCName/NMTOKEN family (values stay the original strings — no canonicalization). Bounded integers that fit a JS number (byte, short, int, the unsigned variants ≤ 32 bit) map to z.number().int() with value-space bounds; the arbitrary-precision xs:integer family and the 64-bit long/unsignedLong map to z.bigint() so no valid lexical is lost to double rounding

Install

npm install xsd-to-zod

zod v4 ships as a regular dependency. For the optional conformance tier (xsd-to-zod/validate), also install:

npm install libxml2-wasm

Usage

CLI

npx xsd-to-zod schema.xsd -o src/generated --format
# → src/generated/schema.zod.ts

npx xsd-to-zod schemas/ -o src/generated --format
# → src/generated/schemas.zod.ts (all .xsd files in the directory)

npx xsd-to-zod types.xsd elements.xsd -o src/generated -n my-api
# → src/generated/my-api.zod.ts

| Flag | Description | |------|-------------| | -o, --out <dir> | Output directory (default: current directory) | | -n, --name <name> | Basename for the generated file (required with multiple inputs) | | -f, --format | Run biome / prettier / eslint --fix on the generated file (project config is used when present; biome/prettier otherwise run with defaults). Warns when no formatter can process the file | | --include-libraries | Include type-definition-only schemas (those without root elements); skipped by default | | --allow-missing-imports | Suppress warnings for unresolved XSD references; unresolved element refs map to z.unknown() in the output instead of being dropped | | --silent | Suppress informational output (warnings are still shown) | | --datatypes <mode> | string (default) keeps the XSD date/time builtins as validated strings; structured parses them into plain objects (XsdDateTime & co.) and serializes back in XSD canonical lexical form |

Bundle all imports and includes into a single self-contained XSD:

xsd-to-zod bundle main.xsd                         # → main.bundled.xsd
xsd-to-zod bundle main.xsd -o dist/schema.xsd      # → dist/schema.xsd
xsd-to-zod bundle main.xsd --format                # formatted output

Validate an XML document:

xsd-to-zod validate data.xml --xsd schema.xsd                    # zod tier (typed parse)
xsd-to-zod validate data.xml --xsd schema.xsd -e libxml2         # conformance tier

Programmatic API

import { parseXsd, irToZod, runPostGenerationFormatting } from 'xsd-to-zod';
import { writeFileSync } from 'node:fs';

const ir = parseXsd(['schema.xsd']);
const { schemas } = irToZod(ir);

writeFileSync('schema.zod.ts', schemas);
runPostGenerationFormatting(['schema.zod.ts']);

Parse and serialize XML

import { parseXml, safeParseXml, serializeXml } from 'xsd-to-zod';
import { orderSchema } from './generated/order.zod.js';

const order = parseXml(orderSchema, xmlString);          // throws ZodError
const result = safeParseXml(orderSchema, xmlString);     // { success, data | error }
const xml = serializeXml(orderSchema, order);

safeParseXml(schema, xml, { validate: false }) skips the final schema validation — a fast path for input already checked by the conformance tier.

Conformance tier (xsd-to-zod/validate)

import { validateXml } from 'xsd-to-zod/validate';

const result = await validateXml(xmlString, xsdString, { url: 'schemas/order.xsd' });
if (!result.valid) {
  console.error(result.issues);  // line-numbered XSD errors
}

Thin wrapper over libxml2-wasm (the reference libxml2 engine on WebAssembly), loaded via dynamic import — it is an optional peer dependency, so browser deployments and zod-tier-only consumers never pay for it. The url option lets relative xs:include/xs:import resolve (from the filesystem in Node).

Typical upload gate: validateXml first (contract check with line-numbered errors), then parseXml (typed data + user-friendly zod issues).

Working with generated schemas

Every emitted complex-type schema is wrapped in z.lazy(() => ...) so cyclic type references and forward references load without errors. z.infer<typeof FooSchema> resolves through the lazy wrapper transparently.

If you need to call .extend(), .pick(), .omit() or any object-only method on a generated schema, unwrap it first via the Zod v4 lazy getter:

import { orderSchema } from './generated/order.zod.js';

const inner = orderSchema.def.getter().def.getter();   // root lazy → type lazy → ZodObject
const extended = inner.extend({ extra: z.string() });

The xmlRegistry metadata is inspectable too — e.g. xmlRegistry.get(orderSchema)?.root returns the root element QName. Registered metadata is informational; parsing/serialization never requires touching it.

Why trust this?

We ship a multi-tier test suite that exercises the full pipeline on real-world and curated fixtures. Every round-trip test validates: XSD → Zod schemas → parse XML (golden-file compare) → serialize back → re-parse → deep-compare → serialized XML validated against the original XSD using libxml2. A smoke test additionally runs tsc --noEmit over the generated output of every curated fixture, so invalid-TypeScript codegen bugs cannot slip through.

Run it locally (npm run test:quick runs the dev-loop subset without the heavy upstream round-trips):

npm test

Test matrix (~2,580 tests):

| Category | Count | What it covers | |----------|------:|----------------| | Curated round-trip | 37 | Declarations, content models, cardinality, types, entities/CDATA, namespaces, imports, cyclic refs, defaults — serialized XML validated against libxml2 | | Upstream round-trip | 16 (14 ✅, 2 ⏭️) | xmlschema examples + OASIS UBL Invoice/Order | | W3C Boeing | 12 (12 ✅) | ipo1–ipo6 discovered from the .testSet metadata of the w3c/xsdtests submodule | | W3C sun/ms/nist selection | 2,615 (2,600 ✅, 15 ⚠️) | Valid-instance cases from 22 sun/ms test sets + a group-filtered nist datatype pilot; known failures pinned as it.fails with categorized reasons | | W3C negative (invalid instances) | 1,529 (1,523 ✅, 6 ⚠️) | zod tier must reject; lenient acceptances confirmed invalid by libxml2 and recorded in the negative conformance report | | W3C full corpus (main + weekly) | 13,899 (13,879 ✅, 20 ⚠️) | All XSD 1.0 test sets via suite.xml; known failures pinned as it.fails with categorized reasons in tests/w3cCorpusKnownFailures.ts | | Pipeline / CLI / runtime | 90+ | Codegen unit tests, runtime coercion, CLI e2e, conformance tier, facet checks | | Negative | 7 | The zod tier's leniency boundary, pinned (missing required → ZodError, foreign root → structural error) | | Codegen typecheck | 1 | tsc --noEmit over all curated fixtures' generated output |

What the ⚠️ pins mean. The W3C suites are triaged to closure — the goal is zero unexplained failures, not a perfect raw count (the corpus contains cases that are unpassable by design). Every failing case carries a pin with a categorized reason: either libxml2 itself rejects the original instance/schema, or the case exercises a documented out-of-scope feature (e.g. type-library schemas with no global element to generate a root parser from). Pins run as it.fails, and their stated reasons are re-verified by the suite (tests/w3cPinVerification.test.ts) — a pin that stops being true, e.g. because a libxml2 upgrade closes a gap, breaks the build. See docs/TEST_STATUS.md for the full definition of done.

Test data sources

  • testdata/curated/ — hand-authored XSD/XML pairs + negative variants (CC0-1.0)
  • testdata/upstream/xmlschema/ — vehicles, collection, stockquote, menù examples from brunato/xmlschema (MIT)
  • testdata/upstream/oasis-ubl-2.4/ — UBL Invoice + Order subset (OASIS RF on Limited Terms)
  • testdata/upstream/w3c-xsdtests/ — git submodule of w3c/xsdtests, pinned commit (W3C Document License)

Full license attributions in testdata/THIRD_PARTY_NOTICES.md. Current suite status and coverage notes live in docs/TEST_STATUS.md.

Limitations

Not supported by the generator (the conformance tier validates them anyway):

  • Identity constraints (xs:key, xs:keyref, xs:unique)

Zod-tier specifics worth knowing:

  • Mixed content: an element's character data segments are concatenated into _text — their interleaving with child elements is not preserved on round-trip
  • xs:any / xs:anyAttribute wildcards are captured in an open shape and round-tripped; namespace constraints (##other, ##targetNamespace, …) are enforced, but wildcard content itself is not validated (lax tier)
  • Element order and unexpected elements are not enforced (conformance tier covers them)
  • Facets Zod cannot express are not promised (conformance tier covers them)
  • xs:float/xs:double specials INF/-INF/NaN are rejected

Known gaps (tracked as GitHub issues)

  • #10 — element order / unexpected-element enforcement in generated schemas (cardinality bounds are enforced; the rest belongs to the conformance tier)

Contributing

Issues and PRs are welcome on GitHub. Please branch from main and make sure npm test passes before submitting.

License

GPL-3.0-only © Paul Debus