@cerios/xml-poto-codegen
v2.3.0
Published
Generate TypeScript classes with xml-poto decorators from XSD schemas. CLI tool with config-based multi-XSD support.
Readme
@cerios/xml-poto-codegen
Generate TypeScript classes with @cerios/xml-poto decorators from XSD schemas. Turn your XML Schema definitions into fully decorated, type-safe TypeScript code — ready for bidirectional XML serialization.
✨ Key Features
- 🎯 Type-Safe Output — Generated classes with full TypeScript types and decorator metadata
- 📄 XSD Driven — Supports complex types, enumerations, inheritance, namespaces, groups, and more
- 🧼 WSDL Input — Extracts and merges all XSD schemas embedded in a WSDL
<types>section - ✅ Validation Built In — XSD facets (pattern, length, numeric bounds, digits, whiteSpace), fixed values,
xs:list, andxs:choicegroups are emitted as runtime-validated decorator options - 📝 JSDoc from XSD —
xs:documentationannotations become JSDoc on classes, properties, and enums - 🔧 Configurable — JSON or TypeScript config with per-source overrides
- 📁 Flexible Output — One file per class (
per-type) or all-in-one (per-xsd) - 🏷️ Enum Styles — Generate unions, TS enums, or const-object patterns
- 🔗 Multi-XSD — Process multiple schemas with cross-file
xs:importresolution - 🚀 CLI & Programmatic — Use from the command line or integrate into build scripts
- 📦 Zero Config Start — Interactive
initcommand to scaffold your config
📦 Installation
npm install -D @cerios/xml-poto-codegenPeer dependency:
@cerios/xml-potomust be installed in the project where generated code will be used.Requires
@cerios/xml-poto2.5.0 or newer. Generated code importsXmlType,XmlIncludeand@XmlArray({ items }), which earlier versions do not export. The declared peer range is wider than this, so upgrade both together — an older runtime fails at compile time on the generated module rather than at install.
🎯 Quick Start
1. Initialize config
npx xml-poto-codegen initThe init flow asks you to choose either a JSON or TypeScript config file and then scaffolds it in your project root.
Example xml-poto-codegen.config.json:
{
"sources": [
{
"xsdPath": "./schemas/my-schema.xsd",
"outputPath": "./src/generated",
"outputStyle": "per-type"
}
]
}2. Generate classes
npx xml-poto-codegen generate3. Use the generated code
Given this XSD:
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="Person">
<xs:complexType>
<xs:sequence>
<xs:element name="FirstName" type="xs:string"/>
<xs:element name="LastName" type="xs:string"/>
<xs:element name="Age" type="xs:integer"/>
<xs:element name="Email" type="xs:string" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:schema>The codegen produces:
// ──────────────────────────────────────────────
// AUTO-GENERATED — do not edit
// ──────────────────────────────────────────────
import { XmlAttribute, XmlElement, XmlRoot } from "@cerios/xml-poto";
@XmlRoot({ name: "Person" })
export class Person {
@XmlAttribute({ name: "id", required: true })
id: string = "";
@XmlElement({ name: "FirstName" })
firstName: string = "";
@XmlElement({ name: "LastName" })
lastName: string = "";
@XmlElement({ name: "Age" })
age: number = 0;
@XmlElement({ name: "Email" })
email?: string;
}You can then serialize and deserialize immediately with @cerios/xml-poto:
import { XmlSerializer } from "@cerios/xml-poto";
import { Person } from "./generated/person";
const serializer = new XmlSerializer();
const person = serializer.fromXml(xmlString, Person);
const xml = serializer.toXml(person);🛠️ CLI Commands
init
npx xml-poto-codegen initInteractively create a config file. Prompts for config format (json/ts, default: ts), validates XSD paths, asks for output style, then asks for a folder path (per-type) or file path (per-xsd) accordingly.
generate
npx xml-poto-codegen generate⚙️ Configuration
Create xml-poto-codegen.config.json or xml-poto-codegen.config.ts in your project root.
JSON config
{
"sources": [
{
"xsdPath": "./schemas/orders.xsd",
"outputPath": "./src/generated/orders",
"outputStyle": "per-type"
},
{
"xsdPath": "./schemas/products.xsd",
"outputPath": "./src/generated/products.ts",
"outputStyle": "per-xsd"
}
],
"defaultOutputStyle": "per-type"
}TypeScript config
import type { XmlPotoCodegenConfig } from "@cerios/xml-poto-codegen";
const config: XmlPotoCodegenConfig = {
sources: [
{
xsdPath: "./schemas/orders.xsd",
outputPath: "./src/generated/orders",
outputStyle: "per-type",
},
],
defaultOutputStyle: "per-type",
};
export default config;Global options
| Option | Type | Description |
| ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
| sources | XsdSource[] | Array of XSD sources to process |
| defaultOutputStyle | 'per-type' \| 'per-xsd' | Default output style for all sources (default: 'per-type') |
| enumStyle | 'union' \| 'enum' \| 'const-object' | Default enum generation style (default: 'union') |
| useXmlRoot | boolean | Emit @XmlRoot for root elements; @XmlElement when false (default: true) |
| elementForm | 'schema' \| 'qualified' \| 'unqualified' | How local elements are namespace-qualified (default: 'schema') |
| bigIntegerAs | 'number' \| 'string' | How over-wide integer types are generated (default: 'number') |
| requiredPropertyStyle | 'schema' \| 'definite' \| 'initialized' | How required properties are declared (default: 'schema') |
Source options
Every option below overrides its global counterpart for that source.
| Option | Type | Description |
| ----------------------- | ------------------------------------------ | -------------------------------------------------------------- |
| xsdPath | string | Path to the XSD file (required) |
| outputPath | string | Output path. per-type: directory. per-xsd: .ts file path |
| outputStyle | 'per-type' \| 'per-xsd' | 'per-type': one file per class. 'per-xsd': all in one file |
| enumStyle | 'union' \| 'enum' \| 'const-object' | Enum generation style for this source |
| useXmlRoot | boolean | Emit @XmlRoot for root elements, or @XmlElement |
| elementForm | 'schema' \| 'qualified' \| 'unqualified' | How local elements are namespace-qualified |
| bigIntegerAs | 'number' \| 'string' | How over-wide integer types are generated |
| requiredPropertyStyle | 'schema' \| 'definite' \| 'initialized' | How required properties are declared |
🏷️ Enum Styles
Choose how XSD enumerations are generated with the enumStyle option.
"union" (default)
export type StatusType = "active" | "inactive" | "pending";"enum"
export enum StatusType {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}"const-object"
export const StatusType = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
export type StatusType = (typeof StatusType)[keyof typeof StatusType];🌐 Namespace Qualification (elementForm)
XSD's elementFormDefault decides whether local elements are namespace-qualified. It
defaults to unqualified when a schema does not declare it, meaning only globally declared
elements carry the namespace:
<tns:gbavVraag xmlns:tns="http://example.com/v1">
<identificatie> <!-- local: unqualified -->
<indicatie>AFN</indicatie>
</identificatie>
</tns:gbavVraag>elementForm controls whether the generator honours that:
| Value | Behaviour |
| --------------- | ------------------------------------------------------ |
| 'schema' | Default. Honour the schema's elementFormDefault. |
| 'qualified' | Force qualified locals, whatever the schema says. |
| 'unqualified' | Force unqualified locals, whatever the schema says. |
Forcing is an escape hatch for a service whose WSDL disagrees with what it actually puts on the wire — not uncommon with older SOAP stacks. Reading is unaffected either way: deserialization matches elements on their namespace URI, so a response using any prefix is understood.
🔢 Wide Integers (bigIntegerAs)
xs:integer is arbitrary-precision in XSD, and xs:long reaches 9223372036854775807 — both
beyond JavaScript's Number.MAX_SAFE_INTEGER, where 9007199254740993 silently becomes
…992.
| Value | Behaviour |
| ---------- | -------------------------------------------------------------------------------------------- |
| 'number' | Default. Ergonomic, and accepts precision loss above 2^53. |
| 'string' | Generate string for integer types whose totalDigits is absent or exceeds 15 safe digits. |
Integer types bounded by a totalDigits within range stay number under either setting, so
most schemas are unaffected. Reach for 'string' when a schema carries long account,
transaction or document identifiers.
A numeric type constrained by an XSD
patternis always generated asstring, regardless of this option — a pattern constrains the lexical form, which is how a schema expresses "nine digits, leading zero permitted". Coercing such a value to a number would change it.
✳️ Required Properties (requiredPropertyStyle)
A required member is generated either with a default initializer or with a definite-assignment
assertion, which makes tsc demand an assignment under strictPropertyInitialization:
nummer: string = ""; // initializer
str!: string; // definite assignmentBy default the choice is made per property, and it follows the schema. That means two schemas describing the same service can generate differently — a schema declaring its elements against raw built-ins keeps the initializer, while one restricting them does not:
<!-- '' is legal → nummer: string = '' -->
<xsd:element name="nummer" type="xsd:string" minOccurs="1"/>
<!-- '' violates minLength → str!: string -->
<xsd:simpleType name="NmIP">
<xsd:restriction base="xsd:string"><xsd:minLength value="1"/></xsd:restriction>
</xsd:simpleType>The reasoning is that = '' under minLength="1" cannot serialize: it only turns a missing
assignment into a runtime facet error, whereas ! makes the compiler catch it. Set the option to
force one uniform shape instead.
| Value | Behaviour |
| --------------- | ----------------------------------------------------------------------------- |
| 'schema' | Default. Keep the initializer unless the property's own facets reject it. |
| 'definite' | Always !, whatever the schema says. |
| 'initialized' | Always an initializer where one is possible. |
Under 'schema', a '' is considered rejected by a pattern, a minLength/length above 0, or
an enumeration not listing it; a 0 by a minInclusive above 0, a minExclusive of 0 or more, a
negative maxInclusive/maxExclusive, or an enumeration not listing it. A property carrying
default or fixed is exempt — the schema chose that value.
Enum-typed and abstract-typed members always take
!, including under'initialized'. Neither has an assignable initializer: the base type's''/0does not satisfy an enum union, and anabstract classhas nonew Type()to call.
🔧 XSD to Decorator Mapping
| XSD Concept | Generated Code |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Root element + complexType | @XmlRoot({ name: '...' }) |
| Named complexType | @XmlType({ name: '...' }) class (@XmlElement when useXmlRoot: false) |
| Element in sequence | @XmlElement({ name: '...' }) |
| xs:element ref / xs:attribute ref | Resolved to the referenced global declaration's type and facets; always namespace-qualified |
| Repeating xs:choice/xs:sequence | One @XmlArray({ items }) collection, keeping the document order of the differently named siblings |
| mixed="true" complex type | @XmlText({ mixed: true }) beside the typed members, collecting the interleaved text runs |
| Element with maxOccurs > 1 | @XmlArray({ itemName: '...' }) (+ minOccurs/maxOccurs for finite bounds) |
| Attribute | @XmlAttribute({ name: '...' }) |
| simpleContent | @XmlText() |
| Enumeration restriction | enumValues option |
| Pattern restriction | pattern option (multiple xs:pattern facets are ORed) |
| Facet restrictions | length, minLength, maxLength, min/maxInclusive, min/maxExclusive, totalDigits, fractionDigits, whiteSpace |
| fixed="..." | fixedValue option (default when absent, constraint when present) |
| xs:list | list option — space-separated text round-trips as a typed array |
| xs:choice | choiceGroup / choiceRequired options (exclusive-member validation) |
| nillable="true" | isNullable: true (round-trips xsi:nil) |
| xs:annotation/documentation | JSDoc comments |
| xs:any (in a sequence or a choice) | @XmlDynamic() |
| Inline xs:union/xs:list members | Resolved like the attribute form, instead of collapsing to string |
| xs:IDREFS / NMTOKENS / ENTITIES | string[] with the list option |
| complexContent extension | TypeScript extends |
| complexContent restriction | Flattened to a standalone class with the restricted members (no extends) |
| abstract="true" complex type | abstract class |
| Type hierarchies (xsi:type) | @XmlInclude(() => Sub) on the base in single-file mode; per-type relies on @XmlType self-registration |
| xs:import (different ns) | Each imported type keeps its own namespace / element form |
| xs:import / xs:include | Cross-file type resolution |
| WSDL <definitions> documents | Embedded <types> schemas merged; message/portType/binding become an operations.ts |
| wsdl:import | Followed for both WSDL and XSD targets, so a WSDL split over several files generates as one |
| substitutionGroup | Head becomes @XmlArray({ items }) over the head and its substitutes, each with its own type |
| Groups / attributeGroups | Inlined into the containing class |
| xs:anyAttribute | @XmlDynamic() — one anyAttributes member, wherever the wildcard is declared (see below) |
Coverage Notes
Codegen focuses on what can be inferred from XSD structure and constraints. The generated code covers core decorators and common options, but not every runtime-only xml-poto capability.
- Generated from XSD:
@XmlRoot,@XmlElement,@XmlAttribute,@XmlText,@XmlArray,@XmlDynamic,@XmlType, and (single-file mode)@XmlInclude - Not generated (manual only):
@XmlComment,@XmlIgnore - Polymorphism: abstract complex types generate
abstract class; in single-file (per-xsd) mode a base type emits@XmlInclude(() => Sub)soxsi:typeresolves to the subtype. Inper-typemode@XmlIncludeis omitted (it would create an import cycle); subtypes register their@XmlTypeidentity when the barrel is loaded, so load the barrel (or import each subtype) forxsi:typeresolution. complexContentrestriction is flattened: the derived type is a standalone class with only the restricted members, notextends Base(which would re-inherit dropped members).xs:anyAttributeis honoured in every position XSD allows it — on the complex type, on either half ofsimpleContent/complexContent, and on anattributeGroupdefinition reached through any depth of references. However many of those name the wildcard, the class gets a singleanyAttributesmember.- Partial option coverage by design: runtime tuning options such as custom converters/transforms, CDATA toggles, mixed-content behavior flags, and advanced
@XmlDynamicparse/cache/lazy settings are not inferred from XSD and must be added manually when needed. - Parsed but reported as warnings only (no class-level representation): identity constraints (
xs:key/xs:keyref/xs:unique),xs:notation,xs:redefineoverrides,use="prohibited"attributes (omitted), and remote (http(s))schemaLocationreferences (not fetched). xs:patternis translated to a JavaScriptRegExp. XSD's name-character escapes (\i,\I,\c,\C) are expanded; constructs JavaScript cannot express — character-class subtraction ([a-z-[aeiou]]) and Unicode block escapes (\p{IsBasicLatin}) — are dropped with a coverage note, because generated code passes the pattern tonew RegExpat decoration time and an untranslatable one would throw on import.- Two distinct complex types that map to the same class name (same local name in two merged namespaces, or two inline types sharing an element name) are kept apart: the second is suffixed (
AddressType2) and reported. Anxs:redefinepair is exempt — its shared name is intentional.
WSDL Input
Files whose root element is <definitions> (SOAP/WSDL) are detected automatically — the extension does not matter (.xsd or .wsdl). All <schema> elements inside the WSDL <types> section are extracted, inherit the namespace declarations from <definitions>, and are merged into a single schema before generation.
wsdl:import is followed, so a WSDL split across files generates as one document: point the CLI at the file naming the service and the imported <types>, <message> and <portType> come with it. An imported operation is still paired with the <binding> that annotates it even when the two live in different files. Each file resolves its own relative locations against its own directory, mutual and repeated imports are visited once, and a wsdl:import may name a bare .xsd (which merges like an xs:import, keeping its own namespace) as WSDL 1.1 allows. Remote http(s) locations are reported and skipped, exactly as for xs:import.
A WSDL additionally produces an operations.ts, pairing each operation's soapAction with the classes it exchanges:
export const CompetentPortOperations = {
stelGbavVraag: {
soapAction: "stelGbavVraag",
input: GbavVraag,
output: GbavAntwoord,
faults: { gbavException: GbavFout },
},
} as const;It is data, not a client — it composes with @cerios/xml-poto's SoapSerializer without dictating a transport, and faults is keyed to drop straight into that serializer's faultDetailTypes option. operations.ts is left out of the generated barrel index.ts (which it imports from), so import it directly.
<service> and its ports are ignored: the endpoint URL is deployment configuration, not schema. RPC-style and SOAP-encoded operations, and multi-part messages, are reported in the coverage notes and skipped rather than half-generated — only single-part document/literal maps cleanly to one class.
📁 Output Styles
per-type (default)
One file per class/enum plus a barrel index.ts:
src/generated/
├── person.ts
├── address-type.ts
├── status-type.ts
└── index.tsper-xsd
All types in a single file:
src/generated/
└── my-schema.tsDeclaration Order & Circular Types
Generated classes are always declared dependency-first (base types and referenced types before their dependents), regardless of the declaration order in the XSD — the order is stable, so schemas already in a valid order generate unchanged output.
Circular and self-referencing types (e.g. a Section containing Section children), which no declaration order can satisfy, are emitted as lazy references (type: () => Section). In per-type mode, classes connected by an extends relationship inside a reference cycle are placed together in one file (named after the base class), because a cyclic extends split across ES modules cannot evaluate in any import order.
💡 Why Codegen?
Without codegen ❌
// Manually write decorated classes for every XSD type
@XmlRoot({ name: "Person" })
class Person {
@XmlAttribute({ name: "id", required: true })
id: string = "";
@XmlElement({ name: "FirstName" })
firstName: string = "";
// ... dozens more properties, easy to get wrong
}With codegen ✅
# One command, always in sync with your schema
npx xml-poto-codegen generateBenefits:
- ✅ Always matches the XSD — no manual drift
- ✅ Handles complex schemas with inheritance, groups, and namespaces
- ✅ Regenerate when schemas change
- ✅ Consistent decorator options and property types
🎓 Best Practices
Commit your config, not the generated code — Add
src/generated/to.gitignoreand run codegen as part of your build pipeline.Use
per-typefor large schemas — Keeps files small and enables tree-shaking.Pin your enum style per-source — Override
enumStyleat the source level when different schemas need different patterns.Regenerate after schema changes — Run
npx xml-poto-codegen generatewhenever your XSD files are updated.
🤝 Contributing
Contributions are welcome! Please open an issue or pull request on GitHub.
📄 License
MIT © Ronald Veth - Cerios
