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

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:

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:

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

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:

On a value schema (a property, an array element), the branches contribute to the property’s type instead:

References

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:

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.