Generators
A generator renders compiled schema records as source files. Seventeen per-class generators exist:
| Name | Files | Contents |
|---|---|---|
plain_ruby |
.rb |
Mutable Ruby classes, no type annotations. |
sorbet_ruby |
.rb |
Mutable Ruby classes with inline Sorbet sig blocks, # typed: strict. |
rbi |
.rbi |
Sorbet interface files: signatures only, empty bodies. |
rbs |
.rbs |
RBS signature files. |
dsl_plain_ruby |
.rb |
Instance DSL builders, no type annotations. |
dsl_sorbet_ruby |
.rb |
Instance DSL builders with inline Sorbet sig blocks, # typed: strict. |
dsl_rbi |
.rbi |
Sorbet interface files for the instance DSL builders. |
dsl_rbs |
.rbs |
RBS signature files for the instance DSL builders. |
dsl_parser |
.rb |
Prism parsers that reconstruct DSL documents without executing their source. |
partial_plain_ruby |
.rb |
Partial classes, no type annotations. |
partial_sorbet_ruby |
.rb |
Partial classes with inline Sorbet sig blocks, # typed: strict. |
partial_rbi |
.rbi |
Sorbet interface files for the partial classes. |
partial_rbs |
.rbs |
RBS signature files for the partial classes. |
plain_ruby_enums |
.rb |
Enum classes with frozen member instances. |
sorbet_ruby_enums |
.rb |
Strictly typed T::Enum subclasses. |
rbi_enums |
.rbi |
Sorbet enum member declarations. |
rbs_enums |
.rbs |
Signatures for the plain enum API. |
The data, DSL, and partial generators receive object records; the enum writers receive qualifying enum records. All format their output with SyntaxTree (syntax_tree for Ruby, syntax_tree-rbs for RBS). Pass format: false to a generator instance to skip that step.
Only the four *_enums generators write enum files. They use the normal class names without a Dsl or Partial prefix. Selecting any of them defaults the whole run to enums: :classes; otherwise the default is :unions. Explicit mode settings override this default. See Enums for qualification and mode rules, and Enum classes for the member API.
What data generators emit
For each object schema the compiler found, one file per class:
-
A banner naming the file as autogenerated and the command that wrote it, worded after tapioca’s generated-file header.
-
A keyword-argument constructor. Required properties are positional keyword arguments without defaults; every other property defaults to
nil, and properties carrying a schemadefaultuse that value. -
One accessor per property, plus its entry in
ORIGINAL_KEYS. A property with adescriptiongets it as a comment above the accessor, and the plain format then declares one accessor per line. -
Catch-all accessors
[]and[]=when the class accepts extra keys. -
from_handto_hfor loading and dumping, with onecoerce_method per property whose values carry a nested class or a coercing scalar format. Each conversion is a frozen literal mirroring the slot’s type structure. The property methods hand their literals toCoercionHelper.coerce_spec, the interpreter theauxiliary_filesgenerator writes once per output directory, so arrays, tuples, maps, catch-all values, and union branches all convert; class entries dispatch throughCoercionHelper.coerce_class_slot, which carries the symmetric load/dump dispatch once, and a slot that is one scalar coercer calls it directly. The built-in coercers live in that same helper module, underCoercionHelper::Coercers, and a class file that calls one requires the helper (a project coercer is referenced by name).
The details live in Generated code.
With immutable: true on a generator instance, or --immutable on the command line, the four data generators write frozen value objects instead: readers instead of accessors, a with copier, and value equality. See Immutable classes.
The four DSL builder generators write modules whose methods define one instance of the schema, to mix into a class or to run through build. They nest under a Dsl module level and a dsl/ file prefix, so both families can share an output directory. The call grammar, the parse contract, and the Sorbet story live in The instance DSL.
dsl_parser
dsl_parser writes parser modules under MyApp::Parser, in parser/. It also writes parser/runtime.rb and parser/parse_error.rb automatically. No separate support generator is needed.
schematrix -g dsl_parser -m MyApp -o out input.json
Each module provides parse(source, filename: "(dsl)") and parse_file(path), returning a document Hash. The methods can also be included in an application class and overridden with super. Generated parsers need Prism 1.4 or later and the standard library, but no Schematrix, builder, or enum classes at runtime. They resolve enum constants statically and reject source outside the DSL grammar. Configured scalar readers are an explicit trusted exception for nonliteral values in formatted slots.
The parser refuses immutable: true and optionals: :maybe, as the builders do. It does not apply defaults, check required properties, or validate schemas. See The instance DSL for accepted source forms, errors, and scalar readers.
plain_ruby
The plain format writes runnable Ruby with no runtime dependencies. person.schema.json from Getting started generates a class you can require and use directly; see that page for the full output. The same schema with a nested object produces one file per class, nested under directories that mirror the class nesting.
sorbet_ruby
The Sorbet format writes the same classes with sig blocks and real bodies, under a # typed: strict header. Loading the file requires the sorbet-runtime gem: the class body calls T.let eagerly and carries sig blocks. Type checking with srb tc requires the sorbet gem.
# typed: strict # DO NOT EDIT MANUALLY # This is an autogenerated file for the MyApp::ComplexObject class. # Please instead update this file by running `schematrix`. module MyApp class ComplexObject ORIGINAL_KEYS = { "name" => :name, "address" => :address }.freeze sig do params( name: T.nilable(String), address: T.nilable(ComplexObject::Props::Address) ).void end def initialize(name: nil, address: nil) @name = name @address = address @_extra = T.let({}, T::Hash[String, T.anything]) end sig { returns(T.nilable(String)) } attr_accessor :name sig { returns(T.nilable(ComplexObject::Props::Address)) } attr_accessor :address # ... the []= accessor, coerce_ methods, from_h and to_h follow, # with the same bodies the plain_ruby generator emits end end
rbi
The RBI format writes signature-only files with empty method bodies, the shape sorbet consumes for static analysis. Signatures match the sorbet_ruby output. # typed: strict applies.
sig { params(name: T.nilable(String)).void } def name; end sig { params(hash: T::Hash[String, T.anything]).returns(Person) } def self.from_h(hash); end
rbs
The RBS format writes .rbs signatures, one module tree per class. Type names inside a signature are relative (ComplexObject::Props::Address), which resolves lexically from the enclosing class. The signature describes the same surface as the Ruby output: the constructor takes the same required keywords (a required property typed ["string", "null"] stays a required argument), and ORIGINAL_KEYS and the coerce_ class methods the Ruby file defines are declared alongside the accessors.
module MyApp
class Person
ORIGINAL_KEYS: Hash[String, Symbol]
attr_accessor first_name: String
attr_accessor last_name: String
attr_accessor age: Integer?
def initialize: (
first_name: String,
last_name: String,
?age: Integer?
) -> void
def []: (String) -> top
def []=: (String, top) -> top
def self.from_h: (Hash[String, top] hash) -> Person
def to_h: -> Hash[String, top]
end
end
The partial_ generators write partial classes: every property wraps in the Maybe type, required ones included, so the class records which keys its document carried. They nest under a Partial module level and a partial/ file prefix, the way the builders nest under Dsl, so a data generator and a partial generator can write to one directory and one module:
app/generated/person.rb # MyApp::Person, the data class app/generated/partial/person.rb # MyApp::Partial::Person
The partial family types every slot with the Maybe type; see Maybe optionals for the type and the loading and dumping rules, which the partial classes follow for every slot.
A partial is the natural type of a patch document, of a projection, and of any record of which only some fields are known.
auxiliary_files
The auxiliary-files generator. It is run-once: it writes no classes; it writes one coercion_helper.rb (plus coercion_helper.rbs declarations) per output directory, holding the CoercionHelper module the data formats’ coercion methods call into. Later auxiliary files join the same run. coerce_class_slot is the class-shaped dispatch (a Hash loads through the target class’s from_h, an instance dumps through its to_h, anything else passes through); coerce_spec is the interpreter that walks a conversion literal and handles arrays, tuple positions, maps, union branches and formatted scalars.
Any run that selects a data generator selects auxiliary_files alongside it, pointed at the same output directory:
schematrix -g plain_ruby -g auxiliary_files -m MyApp -o app/generated person.schema.json
A class requires the module when any of its conversions is structured (a nested class, an array, a map, a tuple, or a union), or when any of them calls a built-in coercer, because the built-in coercers live in the module. A slot whose conversion is one project coercer calls that coercer by name, and a class with only such slots emits no reference to the module. A run that omits the auxiliary generator leaves the requires dangling, which fails at load time.
Type mappings
How a JSON Schema type renders per notation:
| Schema type | Sorbet (rbi, sorbet_ruby) |
RBS (rbs) |
|---|---|---|
string |
String |
String |
integer |
Integer |
Integer |
number |
Numeric |
Numeric |
boolean |
T::Boolean |
bool |
null |
NilClass |
nil |
| object | the generated class | the generated class |
| array | T::Array[E] |
Array[E] |
| map | T::Hash[String, V] |
Hash[String, V] |
no type at all |
T.anything |
top |
false schema |
T.noreturn |
top |
A scalar slot’s format picks its row through the scalar type table: date renders Date and date-time renders Time, with the value converted on load and dump wherever the formatted type appears (elements, tuples, map values, union branches included). A union whose branches convert the same JSON type through different coercers fails generation with CoercionConflictError. A format the table does not list renders as its bare type, and a generator’s scalar_types: option adds the project’s own rows:
Schematrix::Generators::SorbetRuby.new( "app/generated", "Billing", scalar_types: { ["string", "money"] => Schematrix::ScalarType.new( sorbet: "Money", rbs: "Money", coercer: Money ) } )
The option merges over the built-in table, so a built-in format can be remapped. A coercer must be a named module answering coerce(value); anything else fails the generator’s construction, before any file is written. See Library API.
Absence wraps a type: an optional property renders as T.nilable(T) in Sorbet and (T)? in RBS. A union of declared types renders as T.any(A, B) and A | B. An intersection renders as T.all(A, B) and A & B. plain_ruby emits no annotations; optionality shows up as constructor defaults there.
Inside a union, a false branch contributes no term in either notation: it accepts nothing, so the other branches speak for the slot.
RBS renders top where Sorbet renders T.anything: any value assigns into top, but a read from it must be narrowed before use. Sorbet’s T.anything has the same shape.
A property typed only ["object", "null"] through a $ref keeps its class and renders nilable instead of collapsing to nil.
Setter signatures on the DSL builders type the value the call admits, not the slot’s nilability: an optional integer property takes params(value: Integer), and an explicit nil rides the union only when the schema admits null. A block is bound to the child builder module in Sorbet, T.proc.bind(Person).void, which is how a block run with instance_exec is typed. RBS declares the block bare, { -> void }: the notation has a self binding, but the formatter the gem uses drops it. Builders type their document hash T::Hash[String, T.untyped] in Sorbet and Hash[String, top] in RBS: setters call concat, <<, and merge on their slot reads, and those are the types that admit every call.
Maps
An object schema with no own properties and a non-closed additionalProperties is a map, not a class: the slot types as Hash[String, V] where V is the catch-all value type, and no class file is generated for it.
{
"type": "object",
"additionalProperties": { "type": "integer" }
}
This schema generates nothing; a property holding it types as Hash[String, Integer?]. The Hash value type carries an explicit nil term only when the catch-all admits null.