Inside the ShEx Coder: The IR Map, the Extras Ledger, and What Round-Trips
Most schema formats name things locally: a SQL column is `full_name`, an Avro field is `full_name`, and any connection to the wider world has to be bolted on with annotations. ShEx arrives different. Its predicates and shape names are IRIs — `schema:name` *is* `https://schema.org/name` — which means a ShEx document carries its cross-standard identity in its bones. Our ShEx coder in CoreModels, by ARAMAI, is built around that fact, and this article is the full technical account of it: exactly what maps into our intermediate representation (IR), what rides in the preserved-extras ledger, every lossiness record the coder can emit, and precisely what survives a round trip.
Inside the ShEx Coder: The IR Map, the Extras Ledger, and What Round-Trips
Most schema formats name things locally: a SQL column is full_name, an Avro field is full_name, and any connection to the wider world has to be bolted on with annotations. ShEx arrives different. Its predicates and shape names are IRIs — schema:name is https://schema.org/name — which means a ShEx document carries its cross-standard identity in its bones. Our ShEx coder in CoreModels, by ARAMAI, is built around that fact, and this article is the full technical account of it: exactly what maps into our intermediate representation (IR), what rides in the preserved-extras ledger, every lossiness record the coder can emit, and precisely what survives a round trip.
The coder is bidirectional under the format key shex. Its external representation is the ShExC (compact syntax) document as a string, and the subset it models is: shapes, triple constraints, xsd: datatypes, shape references (@Shape), inline value sets ([ ... ]), the cardinality operators (?, *, +, {n,m}), EXTENDS inheritance, and PREFIX declarations.
That entire subset fits in one small document:
PREFIX schema: <https://schema.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
schema:PersonShape {
schema:name xsd:string ;
schema:email xsd:string * ;
schema:birthDate xsd:date ?
}
schema:EmployeeShape EXTENDS @schema:PersonShape {
schema:jobTitle xsd:string ;
schema:workStatus [ "active" "leave" "terminated" ]
}
Decoded, that is two Types (Person and Employee, suffixes stripped), five Elements, one Taxonomy from the value set, a single parent link from EXTENDS, and a prefix map riding in extras — the mechanics the rest of this article specifies table by table.
The structural map
The IR describes schemas as Types (record-like structures) containing Elements (fields), plus Taxonomies (controlled term lists), with every node carrying an annotation bag: mapsTo links to external standards, free-form Extras, and provenance (ShEx-decoded nodes are stamped with source format ShEx and the originating shape or predicate).
| ShExC construct | IR construct |
|---|---|
Shape (schema:PersonShape { ... }) | Type; id is the shape name, label is the local name with the Shape suffix stripped (Person) |
Triple constraint (schema:name xsd:string ;) | Element; id is <shapeName>|<predicate>, label is the predicate's local name (name) |
Datatype constraint (xsd:integer) | Primitive value type (see the datatype tables) |
Shape reference (@schema:PersonShape) | Type reference — a real link to the target Type, not a string |
Value set ([ "draft" "active" ]) | Taxonomy with one term per entry; id is <elementId>|enum |
EXTENDS @Parent | The Type's parent — single inheritance |
| Cardinality symbols | Required flag + single/collection cardinality (next table) |
PREFIX p: <iri> | Schema-level prefix map, preserved in extras |
Cardinality is a two-axis decode — is the element required, and is it collection-valued:
| ShExC | Required | Cardinality |
|---|---|---|
| (none) | yes | single (ShEx's default is exactly one) |
? | no | single |
+ | yes | collection |
* | no | collection |
{n,m} | yes when n ≥ 1 | collection when m > 1, with min/max kept in the IR |
The numeric form is the one asymmetry: {1,4} decodes faithfully (required, collection, min 1, max 4), but the encoder writes only the four symbols, so it re-emits as +. The bounds live on in the IR — formats that can express them receive them — but ShEx output rounds to the nearest symbol. {0,1} is recognized as exactly ?.
Datatypes, both directions
Decoding xsd: datatypes to IR primitive kinds:
| ShEx datatype | IR kind |
|---|---|
xsd:integer, xsd:int, xsd:long, xsd:short | Integer |
xsd:decimal, xsd:double, xsd:float | Double |
xsd:boolean | Boolean |
xsd:dateTime, xsd:date, xsd:time | DateTime |
xsd:string, xsd:anyURI, anything unknown | String |
Encoding fresh IR back to ShEx:
| IR kind | ShEx datatype |
|---|---|
| Integer | xsd:integer |
| Double | xsd:decimal |
| Boolean | xsd:boolean |
| DateTime | xsd:dateTime |
| String, RichText | xsd:string |
Read those two tables together and you see a many-to-one funnel: xsd:short and xsd:long both become Integer. That would be lossy — except it isn't on a round trip, because of the extras ledger below.
A non-xsd node constraint — ., LITERAL, NONLITERAL, a bare IRI, or a class IRI used as a datatype — has no IR primitive. The decoder approximates it as String and records a TypeApproximation: "ShEx value constraint '.' has no IR primitive; approximated as String." The original token is still preserved, so ShEx-to-ShEx re-emits it exactly; the approximation is real only for other target formats, and the ledger says so.
The extras ledger
Everything the IR's type system cannot hold natively, but the original document needs back, rides in dotted, coder-prefixed Extras keys — namespaced so no two coders ever collide:
| Key | On | Holds |
|---|---|---|
shex.prefix.<p> | the schema | each prefix's namespace IRI (the full prefix map) |
shex.shapeName | a type | the original prefixed shape name (schema:PersonShape) |
shex.predicate | an element | the original prefixed predicate (schema:email) |
shex.datatype | an element | the verbatim datatype token (xsd:short, xsd:anyURI, even .) |
On encode, preserved values win over synthesis: a ShEx-born element re-emits its exact predicate and its exact datatype, whatever the IR kind funneled to. This is how xsd:date survives being represented as DateTime, and xsd:anyURI survives being represented as String. The prefix map is re-emitted sorted, filtered to the prefixes actually used (with xsd always retained). Five prefixes are built in as defaults on both sides: xsd, rdf, rdfs, schema (https://schema.org/), and our own cm (written here as https://coremodels.example.com/ns/).
The mapsTo lift — no annotation syntax required
Because predicates are IRIs, the decoder lifts every one into the element's mapsTo annotation automatically: standard = the prefix (schema), URI = the expansion (https://schema.org/name). Shapes get the same treatment at type level with one refinement: schema:PersonShape conventionally validates instances of schema:Person, so the coder strips the Shape suffix and maps the type to the class IRI https://schema.org/Person. Two deliberate exclusions: the a / rdf:type predicate is never lifted (it states rdf-typing, not a property meaning), and a prefix the document never declared cannot be expanded, so it produces no mapping.
This is the coder's biggest dividend. mapsTo is the cross-standard backbone our whole transform stack reads: a ShEx schema over schema.org predicates aligns — automatically, by URI — with any schema in any other format that maps to the same URIs, and mapping guides can set autoMatchByMapsTo instead of matching fragile labels.
Encoding runs the same wire in reverse for IR that was not born in ShEx: no preserved predicate means the encoder tries to reverse a mapsTo URI back into a prefixed name (longest-namespace match wins), and only then falls back to synthesizing cm:<label>. Shape names synthesize as cm:<PascalCaseLabel>Shape. So a SQL-born column with a mapsTo of https://schema.org/name exports as the real schema:name, while its unmapped siblings export under cm:.
The lossiness inventory
The decoder emits exactly two kinds of ledger records:
- SemanticNarrowing — a shape with multiple
EXTENDSclauses. The IR holds single inheritance, so the first parent wins: "ShEx multiple EXTENDS (2) narrowed to a single IR parent 'schema:PersonShape'." - TypeApproximation — the non-
xsdnode constraints described above, one record per occurrence, with the original token named.
Equally important is what is skipped without a ledger entry, because the coder does not model it at all: BASE, IMPORT, and start statements; the CLOSED and EXTRA shape modifiers; anything between a constraint's value expression and its terminating ; — inline // annotations and facet-style qualifiers alike; comments; and stray literals between shapes. Parsing never hard-fails on these — they are consumed and dropped. If your governance depends on CLOSED semantics or annotation content, know that they do not enter the IR. And one honest failure mode: an empty or whitespace-only document is rejected outright with the error "The ShExC document is empty." at path $.
What round-trips, exactly
Our test suite pins the invariant decode → encode → decode: the second IR is structurally identical to the first — same types, parents, element labels, value types, required flags, and cardinalities. Layered on top of that:
- Exact re-emission for ShEx-born schemas. Prefixes, shape names, predicates, and verbatim datatypes come back from the extras ledger character-for-character.
- Cross-format meaning survives. The suite also proves ShEx → IR → JSON Schema lands the predicate as
"x-maps-to": { "schema": "https://schema.org/name" }on the corresponding property. - Numeric cardinalities round to symbols on ShEx output, as described above.
- Value-set terms are opaque labels. Terms decode as term id/label pairs and re-encode as quoted string literals —
[ "male" "female" ]is perfectly stable, but a value set of IRIs would come back quoted as literals rather than IRIs. Use datatype or shape constraints for IRI-valued properties you need to round-trip.
That is the whole machine: a one-to-one structural map, a dotted extras ledger for exact re-emission, an automatic mapsTo lift that turns RDF's global naming into cross-format alignment, and a lossiness inventory short enough to memorize. The same anatomy — map, extras, ledger — repeats for every format we support; the transform section of the CoreModels docs covers the rest of the lineup.