Mapping JSON Schema and OpenAPI → TSON Schema (input for tson-part3-json) — Revision 2
Status: Non-normative report · Prepared against TSON 2026 Revision 32 (Working Draft) · Input to the Revision 33 development cycle. References to “planned additions” describe design directions under consideration for future revisions, not committed specification. Produced with AI assistance (Anthropic Claude) in collaboration with the TSON author; citations of external standards should be verified against primary sources before normative use. Schema source files are cited with the
.tnextension per the current draft-period convention (.tn1is reserved for the finalized version 1 release).
Scope and framing (revised)
This revision incorporates two decisions that reshape the mapping from the first pass:
- No schema round-trip requirement. The goal is one-directional: convert JSON Schema/OpenAPI to a TSON schema that validates JSON documents at least as strictly as the source schema, ideally more strictly. Every “lossy” verdict from the first pass becomes a directional choice: strengthen (adopt the stricter TSON semantics) or drop-with-report (constraint cannot be expressed; conversion emits a machine-readable warning). The invariant the converter must preserve: every document rejected by the source JSON Schema is rejected by the converted TSON schema. Strictness means accepts-a-subset, never accepts-a-different-set.
- A small set of planned TSON additions (§E, Closing the Gap) resolves the largest structural mismatches — discriminated sums, the
additionalPropertiesmixed form, nullability, and enum/annotation coverage — without adding open types, negation, or conditionals to TSON.
TL;DR
- A large core subset of JSON Schema 2020-12 maps cleanly onto TSON Part 2 constructs.
formatmaps upward to validated core types (email,uuid,datetime, …), converting decorative annotations into enforced constraints — the flagship strictness win. - JSON Schema’s constraint-algebra keywords (
not,if/then/else,dependentSchemas,unevaluated*,$dynamicRef) remain excluded by design. They are drop-with-report, and the conversion report doubles as a migration worksheet. - OpenAPI’s
discriminatordecomposes into two jobs: inheritance (→ TSON composition, where TSON is stronger than the source, since OpenAPI’s allOf-form discriminator is explicitly non-validating) and dispatch (→ TSONchoice+ a planned kernel-leveldiscriminatorfield token). The mapping table (mapping) is derived from per-variantREQUIRED_FIXEDfields, not declared — eliminating drift by construction. - The
additionalPropertiesmixed form no longer requires an open record type. The planned rest field keeps records closed in the model (the catch-all is an ordinary declaredmap<text, X>field) and makes openness purely a JSON-encoding flattening transform — the Jackson@JsonAnySetter/.NET[JsonExtensionData]model lifted into the spec.
A. TSON’s schema constructs (from Part 2, meta-kernel, and core — current draft)
Kinds (meta-kernel §4.1): every type is exactly one of ATOM, PRODUCT, SUM, REFERENCE. Operations (§4.3): record construction {…}, constructor application !C v, composition A & B & {…} (IS-A preserved; supertype field sets must be disjoint), subtraction - {fields} (breaks IS-A), refinement ^ {…}, instantiation !T v.
Field states (§5.2): REQUIRED (default), REQUIRED_DEFAULT (~ v, injected on decode), REQUIRED_FIXED (= v), OPTIONAL (?), OPTIONAL_FIXED (? = v), OPTIONAL_FIXED-absent (? = _).
Collections: [T], [T; N..M], tuples, set<T> (unordered + unique), map<K,V>, vector<T,S>.
Sum vocabulary: choice (A | B) — SUM over ≥2 distinct variant types; tag = out-of-band !variant annotation, omissible only when the resolver proves disjoint: true AND the encoding can recover the variant structurally; record-set disjointness is a MAY-prove, so tag-free encoding is never guaranteed without help. @disjoint — author assertion (error if refuted, warning if unprovable). Field groups (§5.11) — labelled sums inside a record: exactly-one / at-most-one member present, discriminated by field label. extern, unknown — deferred and universal membership.
Structural defaults that invert JSON Schema’s: fields are required by default (vs. optional), records are closed (§7.2 — unknown fields are errors) vs. JSON Schema’s open-by-default objects.
B. JSON Schema 2020-12 → TSON, construct by construct
Legend: ✅ clean · ⬆ strengthens · ◑ transform · ✗ drop-with-report (or hard error where noted). ◑→✅ / ✗→✅ marks entries resolved by a planned addition (§E).
Instance types
| JSON Schema | TSON | |
|---|---|---|
type: boolean | boolean | ✅ |
type: integer | integer | ✅ arbitrary precision |
type: number | number | ✅ core documents this as the JSON number mapping, exact |
type: string | text | ✅ |
type: array | [T] /set<T> | ✅ |
type: object | record (fixed keys) or map<text,V> (open keys) — see §D triage | ◑ |
type: "null" | planned null atom (§E #4); interim: no core type | ◑→✅ |
nullable type: [X, "null"] | (X | null) once the atom lands; field-position alternative X? conflates absence with null and should not be used for in-band nulls | ◑→✅ |
Numeric / string / array assertions
| JSON Schema | TSON | |
|---|---|---|
minimum/maximum, exclusiveMinimum/exclusiveMaximum | atom refinement ^ { min / | ✅ (exclusive bounds uniform across atoms: planned #2) |
multipleOf | integer_type.multiple_of; decimal multiple_of planned (#2) | ◑→✅ |
minLength/maxLength | text_type.min_length/max_length | ✅ |
pattern (ECMA-262) | text_type.pattern (I-Regexp, RFC 9485) | ◑ translate when semantics-preserving; otherwise fail loudly or annotate as not-enforced — silent drop would weaken validation (see Caveats) |
format: date-time/ | core datetime/date/time/duration/email/uri/uuid/ipv4/ipv6/regex | ⬆ always map to the validated type — no converter flag needed under the no-round-trip framing |
other format values | @-annotation on text | ◑ |
items/prefixItems | array element_type / | ✅ |
minItems/maxItems | [T; N..M] | ✅ |
uniqueItems: true | array unique_items facet surfaced in syntax (planned #3); set<T> only when unordered semantics are acceptable | ◑→✅ |
contains/minContains/maxContains | planned array quantifier facets (#6) | ✗→✅ |
Object assertions (see §D for the dispatch rule)
| JSON Schema | TSON | |
|---|---|---|
properties | record fields | ✅ |
required | field state; converter emits ? on properties not listed | ◑ mechanical inversion |
additionalProperties: false | TSON default (closed records) | ⬆ |
additionalProperties absent | also closed record — a deliberate strengthening; JSON Schema’s silent acceptance of typo’d property names becomes a caught error | ⬆ |
additionalProperties: <schema>, no properties | map<text, S> | ✅ |
properties + additionalProperties: <schema> (mixed form) | closed record + rest field (planned #7) | ◑→✅ |
required + additionalProperties: <schema>, no properties | map<text, S> with requiredKeys (planned #8) | ✗→✅ |
patternProperties (alone) | map with pattern-refined key_type | ◑ |
patternProperties + properties | drop-with-report | ✗ |
propertyNames | map key_type refinement | ✅ |
minProperties/maxProperties | map min_items/max_items | ✅ maps; ✗ records |
dependentRequired/dependentSchemas | ✗ (field groups cover mutual exclusion, not co-requirement; dependentSchemas keys on presence, not value) | ✗ |
Enumerations / constants
| JSON Schema | TSON | |
|---|---|---|
enum of tokens | !enum [...] | ✅ |
enum incl. numbers/ | literal-valued enums (planned #5, after the null atom) | ◑→✅ |
const in a field | field: T = value (REQUIRED_FIXED) | ✅ — load-bearing for discriminators, §C |
default | non-injecting annotation, never REQUIRED_DEFAULT. JSON Schema default doesn’t inject; injecting during validation would pass documents the source schema’s consumers never saw — a semantic change, not a strictness change | ◑ resolved |
Composition / applicators
| JSON Schema | TSON | |
|---|---|---|
allOf (inheritance-shaped: $ref + extension object) | composition Child => Base & {…} | ✅ and stronger — TSON gives real IS-A subsumption |
allOf (overlapping constraint intersection) | drop-with-report; TSON composition requires disjoint field sets | ✗ |
oneOf + discriminator | discriminated choice — §C | ✅ |
oneOf, no discriminator, disjoint variants | choice (+ @disjoint); tag required unless proven | ◑ |
oneOf, same-base-class variants | single-group record (labelled sum, §5.11) — Part 2’s own recommendation | ✅ |
anyOf + discriminator | same as discriminated oneOf (the discriminator exists to remove the overlap ambiguity) | ✅ |
anyOf, overlapping, undiscriminated | choice selects one variant; overlap semantics lost — report | ◑ |
not, if/ | ✗ by design — see §E, “declined” | ✗ |
References / annotations
| JSON Schema | TSON | |
|---|---|---|
$ref /$defs | type references / | ✅ |
$dynamicRef /$dynamicAnchor | ✗ by design | ✗ |
description | @doc | ✅ |
title, examples, readOnly, writeOnly | planned annotations (#1) | ◑→✅ |
deprecated | @deprecated | ✅ |
contentEncoding: base64/ | core base64/base32/hex types | ⬆ annotation → validated type |
contentMediaType/contentSchema | annotation only | ◑ |
C. The discriminator: two jobs, two mappings
OpenAPI’s discriminator (propertyName + optional mapping, legal only adjacent to oneOf/anyOf/allOf, “MUST NOT change the validation outcome”) is doing two separable jobs:
Job 1 — inheritance (allOf form). Maps to TSON composition, and TSON is strictly stronger: OpenAPI’s allOf-form discriminator is non-validating — no JSON Schema keyword connects parent to children, so validating against Pet never consults Cat. TSON composition gives genuine IS-A: Cat => Pet & {…} means a Pet-typed position admits !Cat with full structural checking.
Job 2 — dispatch (which variant is this payload?). OpenAPI dispatches on an in-band property value looked up in mapping. TSON dispatches on the out-of-band !variant tag, or structurally when disjointness is proven. The mapping reconstructs the in-band tag with existing TSON machinery:
Pet => { petType: text, name: text }
CatPet => Pet & { petType: = cat, huntingSkill: text }
DogPet => Pet & { petType: = dog, packSize: integer ^ { min: 1 } }
pet_message => (CatPet | DogPet) @disjoint
The mapping table becomes the fixed values; implicit mapping (no mapping table — schema names are the tag values) becomes petType: = CatPet, the degenerate case where OpenAPI’s in-band tag and TSON’s type-name tag carry the same string. Because record-set disjointness is a MAY-prove, @disjoint documents intent; the planned discriminator token (§E #9) upgrades this from best-effort to guaranteed.
Converter rule for allOf + discriminator: emit both halves — the composition chain (subtyping) and the explicit choice over the enumerated children (dispatch). Emitting only the composition would mirror OpenAPI’s validation semantics but lose the dispatch intent. The children enumerable at conversion time become a closed variant set (sealed reading), whereas OpenAPI inheritance is nominally open; for a strictness-first tool this is a feature, noted in the conversion report.
The envelope pattern ({ petType: "cat", pet: {…} } — tag beside the payload rather than inside it) is the same discriminated sum applied to wrapper records:
CatEnvelope => { petType: = cat, pet: CatPet }
DogEnvelope => { petType: = dog, pet: DogPet }
pet_message => (CatEnvelope | DogEnvelope)
In JSON Schema this is oneOf-over-const-pinned-branches (or an if/then/else chain); dependentSchemas cannot express it (presence-keyed, not value-keyed). No new TSON construct is needed — and the field-level dependent type (“pet’s type is a function of petType’s value” inside one record) is deliberately excluded: it is if/then/else in different clothes, and would make disjointness and subsumption value-dependent. The lift into a sum over whole records is the feature, not the limitation. Trade-offs worth documenting in part 3: envelopes avoid field-name collisions between variants and keep payload types tag-free; the flattened form is what OpenAPI discriminators produce on the wire.
Native TSON needs none of this. !cat {…} in TSON text is the discriminator. The apparatus exists for the boundary where the annotation slot disappears — JSON-encoded TSON data, and JSON documents authored against OpenAPI contracts. The mapping is encoding metadata, not type-system content, and part 3’s encoding rule makes it derived rather than declared (§E #9).
D. Objects: the converter’s triage rule
One dispatch rule covers the whole object space:
- No
properties(or degenerate) +additionalProperties: <schema>→map<text, S>;requiredpresent → addrequiredKeys(#8);propertyNames/patternProperties→ key_type refinement;minProperties/maxProperties→min_items/max_items. - Real per-key schemas +
additionalProperties: falseor absent → closed record (emit optionality inversions mechanically). - Real per-key schemas +
additionalProperties: <schema>(mixed form) → closed record + rest field (#7). The converter must invent a field name for the anonymous tail (extras, configurable, collision-checked against declared properties) — a small cost that doubles as a migration feature: the dumping ground gets a name, appears in generated code as an explicit typed member, and the conversion report flags every invented rest field as “imported openness — consider tightening.” additionalProperties: trueon a shapeless object →map<text, value>.
For reference, the reverse lowering (TSON → JSON Schema, useful for documentation output even without round-trip goals): record → properties + required + explicit additionalProperties: false (state the closure; don’t rely on readers knowing TSON semantics); map → bare additionalProperties: <schema> (+ propertyNames from key_type, minProperties/maxProperties from size facets); rest field → additionalProperties: <X schema>. Non-text map keys have no JSON object form — hard error unless explicitly opted into stringified keys or an array-of-pairs encoding.
E. Closing the Gap — planned TSON additions
These are the additions identified and settled during design review, ordered easiest → hardest, with the gap each one closes. The unifying design discipline: the kernel grows only where a fact cannot be derived or a behavior cannot be desugared; everything else is vocabulary, sugar, or encoding rules. Every addition is activation-gated — no existing schema changes meaning, no existing data changes validity, new rules apply only where the new token appears.
Tier 1 — vocabulary only (no resolver semantics)
- Annotations:
@title,@examples,@read_only,@write_only,@content_media_type. Closes: JSON Schema’s annotation keywords carry over as metadata. Zero impact on validation or kinds. - Numeric facet completion:
multiple_ofon decimal/number; exclusive bounds uniform across all numeric atoms. Closes: fullmultipleOf/exclusive*coverage. unique_itemssurfaced on ordered arrays. Closes:uniqueItems: truewithout the lossy detour throughset<T>’s unordered semantics.
Tier 2 — small type-system additions
- First-class
nullatom, distinct fromvoid/absence. Closes:type: "null"and nullable unions; makes the present/absent/null triad fully expressible. Sequencing note: land this before #5, since real-world heterogeneous enums frequently includenull. Requires tightening §7.3’s “acceptnullatvoidpositions” rule in the same revision. - Literal-valued enums beyond
token(integers, mixed scalars). Closes: JSON Schema heterogeneousenum. Enum membership feeds disjointness derivation, hence Tier 2 not Tier 1. contains/minContains/maxContainsas array quantifier facets. Validation-only, but a new facet class (existential over elements), needing its own conformance wording.
Tier 3 — Record and map extensions
- Rest field — a Record attribute (
rest: <field_name>) naming a declared field of typemap<text, X>that receives unmatched members in the JSON encoding. This replaces the earlieropen_recordproposal entirely. The record stays closed in the model; the catch-all is an ordinary field; openness is purely an encoding flatten/collect transform — the convergent design of Jackson (@JsonAnySetter/@JsonAnyGetter), .NET ([JsonExtensionData]), and Pydantic (extra="allow"), where the tail is runtime plumbing invisible to the type system. Rules: model-not-annotation (it changes decode behavior); at most one rest field per composed record chain; declared field names win — the tail is defined over remaining names; encode-side key collision with a declared field is an error; native TSON text writes the map honestly nested (!config { host: "x", extras: { retry_ms: "500" } }). Interaction rule for part 3: a rest-field record participates in tag-free JSON dispatch only via a declared discriminator, never structurally (it absorbs arbitrary members and can shadow other variants on the wire). Closes: theadditionalPropertiesmixed form — without TSON ever gaining an open type. Subsumption, subtraction, and record-set reasoning are untouched. requiredKeyson map — presence obligations for a homogeneous map (requiredwithoutpropertiesis valid JSON Schema; this is its exact dual). Listed keys must satisfykey_type; required count floorsmin_items. Deliberately does not take per-key types — that would reinvent the mixed form inside the map constructor. The line: maps are homogeneous, possibly with presence obligations; heterogeneity is what records are for; heterogeneity plus a tail is the rest field. Closes: therequired+additionalPropertiesconfig-object pattern.
Tier 4 — kernel additions (the only two, both discriminator-related)
discriminatorfield token — an optional Record/choice attribute naming a single field as the dispatch key. Passes the kernel test because it changes resolver behavior (per the settled rule: anything semantic cannot be an annotation). What it does not do: carry a mapping table. The mapping is derived — the discriminator field must beREQUIRED_FIXEDin every variant with pairwise-distinct values (resolution error otherwise), and any consumer reconstructs value→variant by inspecting the pinned constants. One source of truth; drift is impossible by construction. Semantics:- (a) Resolves ambiguity when multiple fixed fields could serve as dispatch keys (derivation-only would make wire format depend on declaration order).
- (b) Upgrades diagnostics — a missing or duplicate pin becomes “duplicate discriminator value
dogon FishPet” instead of “choice not disjoint, tag required.” - (c) Materialization: a discriminator field loses its fixed-field omission privilege and must be present in the data in every encoding. This breaks a genuine circularity — fixed-field injection happens after variant selection, but selection happens by reading the field — and matches OpenAPI’s “the discriminator property MUST be a required field,” so converted schemas preserve their source contracts.
- (d) The
!varianttag remains legal on discriminated choices but is demoted from selector to assertion: redundant, and if present it MUST agree with the field — mismatch is a validation error, never a precedence question. (Closing off!typediscrimination entirely would fork the data grammar, since instance annotation is how IS-A instantiation works everywhere, not a choice-specific mechanism.) - (e) Discriminators must be
REQUIRED_FIXED, notREQUIRED_DEFAULT— a default-valued discriminator would let absence select a variant, reintroducing the circularity through the back door. Forbid in the first revision; note as a possible future relaxation (easier to add than to remove). - Placement: support declaration on the choice (where checks are naturally closed-world over the enumerated variants) and on a composed-from parent (where OpenAPI declares it), with the parent form inherited by any choice over its descendants; the parent-form guarantee is scoped to the resolution universe.
Closes: OpenAPI
discriminatormaps to a native construct; the JSON encoding gets a deterministic separability rule (below).
sealed(permits list) — deliberately deferred. Apermits: [type_ref]on Record would make composition-from outside the list a resolution error and yield a derived closed subtype set — enabling exhaustive dispatch over[Pet]positions, tag-free encoding for implicit sums, and faithful codegen to Rust enums / Java sealed interfaces (sealed interface Pet permits Cat, Dogis the exact analogue; Java added sealing largely for exhaustiveswitch, the compile-time cousin of tag-free decode). It is genuinely kernel-shaped: a constraint on the composition operation that no library can layer on, the same architectural move as the deriveddisjointfact. Deferred because the deferral is asymmetric-safe: adding sealing later is purely additive, while shipping it now and discovering theextern/schema-chain scoping was wrong means retracting a kernel guarantee. What the interim costs: only the implicit sum —animals => [Pet]can’t earn tag-free dispatch while the subtype set is open. Every explicit choice gets the full discriminator machinery, and the explicit choice is what the converter emits. Open questions to settle with real corpus data before landing it: instantiability of a sealed parent (the OpenAPI conversion wants abstract — a barePetpayload withpetType: "cat"but noCatPetfields should fail), and enforcement scope across schema chains. Note: subtraction already breaks IS-A, so sealing needs no anti-laundering rule — a composed-then-subtracted type simply isn’t aPet.
Declined, by design
The declining is itself gap-management, because it preserves the properties that make the target worth converting to — decidable disjointness and a clean kind algebra:
not— negation makes disjointness and subsumption reasoning undecidable-in-practice.if/then/elseand field-level dependent types — data-dependent validation paths that don’t fit the kind algebra; the discriminated sum covers the fixed-value-conditional cases that matter, and the envelope lift expresses value-dependent siblings as sums over whole records.$dynamicRef— cost to the resolution model vastly exceeds corpus coverage.- General constraint-intersection
allOf— would replace TSON’s disjoint-union composition with meet semantics and complicate IS-A.
These remain drop-with-report; the report is the migration worksheet.
How the additions compose
With #7 and #9 in place, part 3’s separability predicate for JSON-encoded choice data becomes one deterministic rule: a choice is tag-free on the wire iff it declares a discriminator (the field is the wire tag, always materialized) — otherwise an explicit tag member is required. No structural-disjointness heuristics in the encoding, no per-consumer derivation ambiguity, and rest-field records can’t shadow their way into misdispatch. The !C{} head carrier and _ sentinel questions from the first pass remain part 3’s remaining encoding obligations.
Effect on the mapping tables
The additions convert every ◑→✅ and ✗→✅ marked in §B. What remains unexpressible after all additions land: not, if/then/else, value-keyed conditionals, dependentRequired/dependentSchemas, patternProperties mixed with properties, constraint-intersection allOf, unevaluated*, $dynamicRef — all declined-by-design, all drop-with-report. The residual gap is exactly JSON Schema’s constraint-logic fringe, which is the trade the strictness pitch is built on.
Recommendations (revised sequencing)
- Stage 1 — Profile + report format. Define the convertible profile (everything ✅/⬆/◑→✅ above) and the machine-readable drop-report schema. The report is a product surface, not a log: it ranks imported openness (invented rest fields), dropped constraints, and regex translation failures as a strictness audit of the source API.
- Stage 2 — Land Tiers 1–2 (annotations, facets,
nullbefore enums,contains). Order-independent except #4 → #5. - Stage 3 — Land #9 (discriminator) and #7 (rest field), in that order — the rest field’s dispatch rule depends on the discriminator existing. These two plus Tier 2 cover the dominant real-world patterns:
format, nullable, enums, discriminated unions, and the mixed form. - Stage 4 — Ship the converter against real OpenAPI corpora; measure the residual drop-report population.
- Stage 5 — Revisit
sealed(#10) with corpus evidence on instantiability and cross-chain scoping.
Change triggers: revisit if JSON Schema/OpenAPI gains a native tagged-union keyword, or if corpus data shows the declined keywords dominating real APIs (which would argue for scope reconsideration rather than kernel additions).
Addendum (post-review): OpenAPI-specific vocabulary beyond discriminator
Settled in design review after this report’s first revision; recorded here so the mapping tables read complete:
format: int32/int64/float/double(OAS format-registry extensions) map upward toint32/int64/float32/float64— a strengthening in the establishedformatpattern, and the resolution of “JSON integer is unbounded” for OpenAPI corpora specifically. OAS 3.0’sbyte/binaryformats normalize to 3.1’scontentEncoding/contentMediaTypein the dialect front-end;passwordis annotation-only.readOnly/writeOnlycarry OAS 3.0 validation semantics (response-only / request-only) beyond JSON Schema’s annotations, and desugar into two derived views per schema that uses them — a request-view and a response-view type with field states differing per view — rather than a new construct. The converter emits both views under a naming convention (e.g.User.Read/User.Write).nullable: true(3.0) lowers to the null-union in the normalization front-end, as already required by the dialect pass.xml,externalDocs,example→examples, andx-specification extensions are preserved as annotations; well-knownx-conventions may be table-mapped, everything else passes through untouched.
Caveats
- Strictness direction must be audited per keyword. The subtle failure mode is not lossiness but accidental weakening: a silently dropped
pattern,not, orif/then/elsemakes the TSON schema accept documents the source rejected. Policy: translate when semantics-preserving; otherwise fail the conversion or annotate as not-enforced — loudly. Regex specifically: ECMA-262 → I-Regexp (RFC 9485) is the risky direction (I-Regexp is the restricted dialect); the reverse lowering is safe. formatstrengthening changes contracts. A JSON instance that passed withformatunchecked (annotation-only per 2020-12) can fail TSON validation. This is the product’s pitch, but converted-schema adopters should be told explicitly.- Disjointness derivation stays conservative. Until #9 lands, converted discriminated unions carry
@disjointas intent, and tag-free encoding is not guaranteed. After #9, guarantee-by-construction. - Sealed-set closure is a semantic choice. Reading allOf+discriminator hierarchies as closed variant sets diverges from OpenAPI’s nominally-open inheritance; correct for strictness, but flagged per-conversion.
- Version precision on OpenAPI citations: the “MUST NOT change the validation outcome” sentence appears in the 3.1.1 patch text; 3.1.0 §4.8.25 called the discriminator a “hint” without that sentence. Same feature set; cite precisely.
- The Part 2 text reviewed is the 2026 Revision 32 working draft; the additions in §E are design intentions, not frozen spec. Edge cases still needing worked examples before part 3 is normative: recursive discriminated types, choices whose variants are themselves choices, rest fields under composition chains, and envelope/flattened equivalence proofs.