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:

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.