Schema features
This page lists what the compiler understands, keyword by keyword, and what it does with each. The design rule from the manual holds throughout: generated code accepts every instance the schema accepts, and widens rather than rejects when a feature has no exact Ruby rendering.
Dialects
The compiler targets draft 2020-12. At load time it checks the $schema header: a document naming another dialect fails with UnsupportedDialectError instead of being read with wrong semantics. A document without a header is read as 2020-12. Both the https and the http spelling of the meta URI are accepted, with or without a trailing #.
A document must parse to an object or a boolean; anything else fails with InvalidSchemaError.
Types
-
typeas a string or as an array (["string", "null"]renders as a union,String | nilstyle). -
constwith no declared type infers the type from the value:{"const": "active"}types the property asString. Values that do not map to a clean scalar type infer nothing. -
enuminfers value types when no type is declared. Qualifying string and integer enums render as literal unions or generated classes, depending on the run’senumsmode (below). -
A schema that is just
trueaccepts anything: it types as unconstrained (T.anything/top) and creates no class. A schema that is justfalseaccepts nothing and contributes nothing.
The description annotation becomes a comment: above the generated class for an object’s description, and above the accessor, the DSL setter and the signature for a property’s description, in every notation. title is captured but unused. Constraint keywords such as pattern, minimum, minLength never reach the output: the generated classes shape data, they do not validate it.
Enums
An enum qualifies when, after removing null and duplicates, it has more than one value and all values are strings or all are integers. null makes the slot nullable but is not a member. Mixed lists, single-value lists, const, boolean enums, and float enums stay scalar. For qualifying class enums, the members determine the scalar type even when the declared type differs.
The enums mode applies to the whole run:
-
:unionsis the default without an enum generator. RBS spells literal unions such as"active" | "inactive"or1 | 2; Sorbet spellsStringorInteger, since it cannot express those literal unions. -
:classesis the default when any*_enumsgenerator is present. Both notations name the enum class. Explicit mode settings override the default; conflicting settings raiseArgumentError.
Only plain_ruby_enums, sorbet_ruby_enums, rbi_enums, and rbs_enums write enum files. Data, DSL, and partial generators only reference them. :classes without an enum writer logs a warning and continues; the caller must supply the referenced constants. An explicit :unions with an enum writer still writes enum files, but slots use union types rather than the classes.
Inline enums, $defs enums, and enum document roots use the normal naming rules. A $ref to a qualifying enum keeps its target class, so two properties referencing one $defs entry share members. A sibling format that differs from the target creates a local enum class because its members can have different serialized types. Shape-changing siblings, an own enum or const, and folds that mix different enum records can degrade the result to scalar types.
The plain class has frozen, identity-equal members, member constants, values, deserialize, serialize, and bidirectional coerce. The Sorbet class uses T::Enum. deserialize of an unknown value raises KeyError; loading a standalone enum slot has the same contract. Dumping a member returns its document value, with format conversion applied when present. See Generated code for the API.
Enum and format composition follows this table:
| Mode | Slot type | Load and dump conversion |
|---|---|---|
:classes, no coercing format entry |
enum class | enum class’s coerce |
:classes, coercing format entry |
enum class | format conversion, then enum membership on load; reverse on dump |
:unions, no coercing format entry |
literal union in RBS, scalar in Sorbet | none |
:unions, coercing format entry |
format entry’s type | format entry’s coercer |
In class mode, format and enum conversion run in sequence. The enum writer converts each declared value with the format coercer before creating its member. Loading converts the document value, then looks up the member. Dumping serializes the member, then applies the format coercer to produce the document value.
Builtin and project coercers follow the same rule. A date enum stores Date values, so its serialize method returns a Date and deserialize accepts a Date. Its coerce method loads document strings as members and dumps members as strings. A project coercer can normalize legacy spellings to a canonical value; enum membership is checked afterwards.
Arrays and maps convert enum elements and values. Inline tuple enums also convert by position; prefixItems/0 names PrefixItems::N0. Partial slots wrap the same unprefixed enum class in Maybe, and convert present values on load and dump. DSL setter signatures accept members only, serialize them on store, and pass raw values through at runtime. Neither family writes its own enum members. See The instance DSL.
When union branches share a JSON type, enum conversions use :try and a final :pass: unknown members pass through rather than raising. The interpreter stops at the first successful conversion, so a dumped string is not converted back into a member by a later branch. This over-accepts values outside every enum branch. Array and map alternatives also try each distinct conversion in order. The helper tracks success separately from the converted container. Empty containers and null values can succeed without changing any elements. A failed branch does not pass a partially converted value to the next branch. Array and map conversions allocate new containers on load and dump, even when every element stays unchanged. An enum and a builtin format coercer claiming the same JSON type still raise CoercionConflictError during generation.
Formats
A scalar slot’s format picks the Ruby type the slot renders, from the scalar type table the compiler carries:
| Schema shape | Rendering (Sorbet / RBS) |
|---|---|
{ "type": "string" } |
String / String |
{ "type": "string", "format": "date" } |
Date / Date |
{ "type": "string", "format": "date-time" } |
Time / Time |
The date and date-time entries also carry a coercer, and every slot the type algebra renders from a coercing entry converts on load and dump: a document string loads through Date.iso8601 / Time.iso8601, an instance dumps through iso8601, and anything else passes through unchanged. That covers plain scalar properties, array elements and tuple positions, map and catch-all values, and union branches. The coercion code is emitted once per output directory, in the coercion helper module the auxiliary_files generator writes, so the output keeps no runtime dependency on this gem. See Generated code.
One union shape cannot convert: branches that would convert the same JSON type through different coercers, such as anyOf of date and date-time. The value carries no mark of its branch, so generation fails with CoercionConflictError instead of emitting a converter that is wrong for every instance of the branch whose coercer it does not use.
A format with no table entry renders as its bare type, exactly as if it were absent. A project maps its own formats to its own classes through a generator’s scalar_types: option, which merges over the built-in table:
Schematrix::Generators::SorbetRuby.new( "app/generated", "Billing", scalar_types: { ["string", "money"] => Schematrix::ScalarType.new( sorbet: "Money", rbs: "Money", coercer: Money ), ["integer", "id"] => Schematrix::ScalarType.new( sorbet: "Id", rbs: "Id" ) } )
A coercer is any named module answering coerce(value), following the same rule: a document value loads, an instance dumps, anything else passes through. An entry with coercer: nil maps a format to a type without conversion. The schema stays plain JSON Schema with annotation formats, which every other tool reads unchanged. See Library API for the option and Limits for the conversion contract and its one non-convertible shape.
Properties
properties and required drive the constructor and the accessors:
-
A required property with no
defaultbecomes a constructor keyword without a default. -
Every other property becomes a keyword defaulting to
nil, or to itsdefaultvalue when one is declared. -
A property typed
null(alone) is optional:nilis a value it admits.
Objects and additionalProperties
additionalProperties |
Generated shape |
|---|---|
| absent | open catch-all: unknown keys accepted and stored |
false |
closed class: unknown keys dropped |
| a schema | open catch-all, values typed by that schema |
--closed-shapes (or closed_shapes: in the library) flips the absent case to closed, per run.
An object with no own properties and a non-closed catch-all is a map, not a class:
{ "type": "object", "additionalProperties": { "type": "string" } }
This compiles to Hash[String, String?]-style slot types (the value type carries a nil term when the catch-all admits null), and no class file is generated for it. An explicit false catch-all on an object with no properties is an empty object and keeps its class.
Recursive map references remain hashes. At a repeated reference, the compiler uses a hash with unconstrained values instead of expanding the same schema indefinitely. Nested document values pass through unchanged; the generated types do not enforce the recursive constraint beyond that point.
Arrays
-
itemstypes the element. An array withitemsabsent has an open tail: the element type gains an unconstrained term. -
prefixItemstypes positions. A tuple’s element type is the union of the prefix types, theitemstype when present, and the open-tail term when the tail is open.items: falsecloses the tail. -
Arrays of objects:
$refitems keep their target classes; inline object entries insideprefixItemshave no stable class name and fall back to unconstrained. -
An array property in several
allOfbranches intersects its element types; acrossanyOf/oneOfbranches the element types union.
Composition
How a composition keyword compiles depends on where the schema sits.
At the document root, or on any schema typed object, the branches merge into one class:
-
allOf: a property set conjunction. Properties from all branches merge; a property declared in several branches intersects its type declarations (String & Integer);requiredentries union;additionalPropertiesintersects (anyfalsecloses). Afalsebranch would make the whole schema uninhabitable and is skipped. -
anyOfandoneOf: branch properties merge as optional additions to the parent’s own. A property that only some branches mention gains an unconstrained term, because the branches that skip it accept any value for it. -
then,else, and each entry ofdependentSchemas: their properties merge as optional.ifis a condition and contributes nothing. -
These rules apply recursively, so
if/then/elsebehaves as “the document may carry thethenproperties, or theelseproperties, or neither”.
On a value schema (a property, an array element), the branches contribute to the property’s type instead:
-
allOfconjoins: type declarations share their terms (allOf: [{"type": ["string", "null"]}, {"type": ["string", "integer"]}]types the slotString), fully disjoint declarations intersect syntactically (String & Integer), array branches conjoin their element and tuple constraints, a$refbranch keeps its target class (and that class generates), and an inline object branch merges its properties into the property’s own class, as it does underanyOf. Branch facets with no codegen rendering (minLength, …) contribute nothing. -
Scalar branches of the union keywords become union terms:
anyOf: [string, integer]types the slotString | Integer. -
Inline object branches inside
anyOfmerge into the property’s own class. -
Inline object branches inside
oneOfbecome their own positional classes, namedOneOf0,OneOf1, and so on, nested under the property’s class. -
$refbranches keep their target classes and union with the other terms. -
At the document root,
anyOfandoneOfmerge properties exactly likeallOfdoes, because a root always compiles to one class.
References
-
$refto an object schema: the property types as the target’s class, and that class is generated. Chains of$refto$reffollow to the final target. A definition whose own$refsits beside shape-applying keywords is itself the target (2020-12 applies both): the chain stops there, and its sibling$refapplies when the definition compiles. This is the OpenAPI 3.1 schema idiom, where nearly every definition carries thespecification-extensionshook beside its own keywords. -
$refto a scalar schema: the target’s type compiles in place of the reference, except qualifying enum targets registered as classes (see Enums). -
$reftotrue: only the reference’s own siblings remain, which types as unconstrained when the$refstood alone.$reftofalse: the schema accepts nothing. -
Siblings beside a
$ref: draft 2020-12 applies both. When a sibling changes the shape (type,properties,required,items, and friends), the node compiles as the conjunction of the target and the siblings, which becomes its own class. Annotation-only siblings such asdescriptionride along without disturbing the reference. -
$defsschemas get classes like any other object schema, nested under aDefsmodule level. See Names and files. -
$anchor: a plain-name fragment ("#foo") resolves through the document’s$anchorindex. -
$dynamicRef: resolved statically, to the nearest enclosing$dynamicAnchorwith that name between the reference site and the document root, else to the last$dynamicAnchorin the document. When neither exists, the slot types as unconstrained and the fallback is logged at debug level.
Remote references
A $ref whose URI points at another host never touches the network. It resolves to a file under a schema directory, mirroring the URI as host/path:
https://json-schema.org/draft/2020-12/meta/core
resolves, under vendor/schemas (the CLI default), to the first existing file among:
vendor/schemas/json-schema.org/draft/2020-12/meta/core vendor/schemas/json-schema.org/draft/2020-12/meta/core.json vendor/schemas/json-schema.org/draft/2020-12/meta/core.yaml vendor/schemas/json-schema.org/draft/2020-12/meta/core.yml
Rules:
-
--schema-dirmay repeat; directories are searched in order and the first match wins. Explicit directories replace thevendor/schemasdefault, they do not add to it. -
A non-default port stays in the host segment (
example.com:8443/...). -
A URI with a query string cannot map, and a mapped path cannot carry
.or..segments. Both fail withUnresolvableLocatorError. -
A miss fails with an error listing every candidate path.
This repository vendors the eight draft 2020-12 meta documents under vendor/schemas, so schemas referencing https://json-schema.org/draft/2020-12/... resolve when run from a checkout.
Ignored keywords
Keywords with no codegen rendering are skipped, and each skip is logged once per document at debug level. Shapes stay open:
not, unevaluatedProperties, unevaluatedItems, contains, minContains, maxContains, dependentRequired, patternProperties, propertyNames.
Extra keys land in the catch-all where one exists, so ignoring these widens acceptance instead of dropping data.