Polymorphic Types & xsi:type Dispatch¶
XSD type hierarchies built from <xs:complexContent><xs:extension> (and
xsi:type wire selectors) are dispatched by PolyXML's dynamic runtime —
the shared Rust engine behind the native API, polyxml-python, and (as it
gains nested schemas) polyxml-js. This page documents the strategy, its
round-trip behavior, and the known limitations of the static codegen paths.
🧭 Strategy: a type registry on ModelSchema¶
Every runtime ModelSchema carries:
is_abstract— the source schema marked the typeabstract="true".- a
variantsregistry — the concrete derivations eligible for dispatch, matched by its namespace and local name (e.g.urn:veh:Car).
The registry is populated from whichever schema source built the model:
| Source | How variants are discovered |
|---|---|
XSD (ModelSchema::from_ir) |
All transitive xs:extension derivations of the type are built recursively from the SchemaIR. Derived schemas flatten the full base-chain content model: inherited fields precede the type's own fields. |
| Python classes (PyO3) | A __subclasses__() walk of the dataclass/Pydantic hierarchy, keyed by Meta.name (falling back to the class name). Generated Python already emits real class inheritance (class Car(Vehicle):), so inherited fields and metadata flow automatically. |
xsi:type is resolved as a QName using namespace declarations in scope at
the element. The attribute itself must be bound to the XML Schema Instance
namespace. A type with the same local name in a different namespace does not
select a registered variant.
📥 Parse behavior¶
Dispatch is applied at every frame creation point: the document root
(Start and Empty), nested elements, list items, and streamed
iterparse records.
xsi:typenames a registered derivation → the element is parsed with the concrete schema, so base fields and concrete fields both survive.- An abstract declaration without
xsi:typeis rejected. xsi:typenames an unknown type on an abstract base → a loudValueErrorlisting the known derivations:- An abstract type with no registered derivations fails with an explicit escape-hatch message telling you to deserialize the concrete type directly.
- Everything else is tolerant: non-abstract types without derivations
never scan for
xsi:typeat all (zero-overhead fast path), so a plain content attribute literally namedtypeis never mistaken for a selector; unknown selectors on non-abstract types parse as declared.
📤 Serialize behavior¶
When a value is a record built from a registered variant of the
declared schema, the serializer writes the concrete type's fields and
re-emits the selector (xsi:type="t:Car"), declaring the XML Schema
Instance namespace at the root automatically.
Python has two entry points:
# Serialize against the concrete type (default): the element is <Car>.
polyxml.serialize(car)
# Serialize against the declared base: the element stays <Vehicle ...>
# and xsi:type="Car" is re-emitted — a faithful wire round trip.
polyxml.serialize(car, target_type=Vehicle)
Round trip¶
from dataclasses import dataclass, field
import polyxml
@dataclass
class Vehicle:
class Meta:
name = "Vehicle"
abstract = True
id: str = field(metadata={"type": "Element", "name": "id"})
@dataclass
class Car(Vehicle):
class Meta:
name = "Car"
doors: int = field(metadata={"type": "Element", "name": "doors"})
@dataclass
class Fleet:
class Meta:
name = "Fleet"
vehicle: Vehicle = field(metadata={"type": "Element", "name": "vehicle"})
xml = b'<Fleet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><vehicle xsi:type="Car"><id>V1</id><doors>4</doors></vehicle></Fleet>'
fleet = polyxml.deserialize(xml, Fleet) # fleet.vehicle is a Car
assert fleet.vehicle.doors == 4 # concrete field preserved
out = polyxml.serialize(fleet) # b'... xsi:type="Car" ...'
assert polyxml.deserialize(out, Fleet) == fleet
The same registry is reachable from pure Rust:
let ir = polyxml::schema_parser::XsdParser::new().parse_str(xsd)?;
let schema = polyxml::schema::ModelSchema::from_ir(&ir, Some("Root"))?;
let value = polyxml::deserialize(xml, schema.clone())?; // dispatched Record
let out = polyxml::serialize_with_options("Root", &value, &schema, None, Some(true), None)?;
⚠️ Known limitations & escape hatches¶
- Generated static codecs do not dispatch. The compiled serde/quick-xml
codecs (Rust, Java, …) bind each element to one declared type; the Java
codec rejects
xsi:typeoutright. Escape hatch: deserialize the concrete type directly, or route polymorphic payloads through the dynamic runtime (polyxml::deserialize,polyxml.deserialize). polyxml-jsis scalar-flat today. Its schema binding exposes only scalar fields, so complex-typed (and therefore polymorphic) fields are unreachable from JS yet — the dispatch itself lives in the shared core, so no new core dispatch algorithm is needed when the JS binding gains nested schemas.- JSON transcoding drops the selector.
xml_to_jsonkeeps every concrete field (it iterates the record's own schema), but JSON carries noxsi:typemarker, so JSON → XML cannot recover the wire selector. - Discovery timing (Python). Cached schemas refresh their variant registries when used, including nested fields, so subclasses imported after the first deserialize can be discovered on a later call.
- Unknown derivations on abstract bases fail loudly. An
xsi:typenaming an unregistered type on an abstract base is an error. Import the module defining the subclass or deserialize with the concrete type. Non-abstract declarations without a matching registered variant parse as their declared type.
Regression coverage lives in
crates/polyxml-core/tests/test_xsi_type.rs (Rust, 7 cases) and
crates/polyxml-python/tests/test_xsi_type.py (Python, 11 cases).