Generated code

Every data generator produces the same runtime shape: classes that hold data, load documents, and dump documents. This page describes that shape. The samples show plain_ruby output; sorbet_ruby adds signatures to the same bodies, and rbi/rbs declare the same surface. The dsl_ generators produce builders instead, documented in The instance DSL.

Class anatomy

For person.schema.json from Getting started:

module MyApp
  class Person
    ORIGINAL_KEYS = {
      "firstName" => :first_name,
      "lastName" => :last_name,
      "age" => :age
    }.freeze

    def initialize(first_name:, last_name:, age: nil)
      @first_name = first_name
      @last_name = last_name
      @age = age

      @_extra = {}
    end

    attr_accessor :first_name, :last_name, :age

    def [](key)
      @_extra[key]
    end

    def []=(key, value)
      @_extra[key] = value
    end

    # ... from_h and to_h follow, shown below
  end
end

The pieces:

Loading and dumping

from_h builds an instance from a hash as parsed from a document, with String keys:

person = MyApp::Person.from_h(
  { "firstName" => "Ada", "lastName" => "Lovelace", "nickname" => "Ada" }
)
person.first_name # => "Ada"
person["nickname"] # => "Ada" (extra key, rides the catch-all)

Rules:

to_h is the inverse:

person.to_h
# => { "firstName" => "Ada", "lastName" => "Lovelace", "nickname" => "Ada" }

nil slots are omitted from the dump. Absent keys therefore stay absent across a round trip, and unknown keys survive unchanged.

A required keyword fed nil by from_h (the key was absent) does not raise. Loading accepts more than the schema does; the data can be validated elsewhere.

Enum classes

Only the four *_enums generators write enum files. plain_ruby_enums writes dependency-free classes with frozen member instances. sorbet_ruby_enums writes T::Enum subclasses with strict signatures. rbi_enums declares the Sorbet enum members with Name = new in an enums do block; rbs_enums declares the plain enum API.

For an enum with values "employee" and "external", the plain API is:

MyApp::Affiliation.values
# => [MyApp::Affiliation::Employee, MyApp::Affiliation::External]
MyApp::Affiliation.deserialize("employee").equal?(MyApp::Affiliation::Employee)
# => true
MyApp::Affiliation::Employee.serialize # => "employee"
MyApp::Affiliation.coerce("employee") # => MyApp::Affiliation::Employee
MyApp::Affiliation.coerce(MyApp::Affiliation::Employee) # => "employee"
MyApp::Affiliation.coerce(nil) # => nil

values returns members in document order after removing duplicates and null. Plain members and their serialized values are frozen; new is private, and equality remains object identity. deserialize uses Hash#fetch, so an unknown value raises KeyError. Integer enums use integer serialized values and constants such as N1 and N2. coerce deserializes values of the enum’s raw type, serializes members, and passes everything else through. There is no members alias.

Class-mode slots name the enum in both Sorbet and RBS. Their coerce_status methods delegate directly to a fully qualified generated class, for example ::MyApp::Person::Props::Status.coerce(value). A class whose only conversions are scalar enum leaves needs no CoercionHelper. Array, tuple, map, and union conversions use the shared helper. A coercing format entry runs before enum membership on load and after member serialization on dump. The enum writer applies the same coercer to member literals. For example, a date enum stores Date values: serialize returns a Date, deserialize accepts one, and coerce converts between document strings and enum members.

The helper interpreter supports [:try, spec], which rescues KeyError and returns the original value, and [:pass], which returns it unchanged. Unions with :try stop after the first successful conversion. The helper tracks success separately from container identity, so empty collections and null values do not fall through to later branches. A member dumped to a string cannot be deserialized again by a later branch. coercion_result returns [bool, top]; coerce_spec returns only the converted value. See Limits for over-acceptance and conflicts.

Property names

Schema property names become Ruby identifiers:

  1. Snake case: firstName becomes first_name, HTTPServer becomes http_server.

  2. A leading $ is dropped: $id becomes id, $ref becomes ref.

  3. Characters an identifier cannot carry become underscores: web/hook becomes web_hook.

  4. A name left with nothing representable (only punctuation, or script without Latin characters) folds to its UTF-8 hex instead: 名前 becomes e5908de5898d, so distinct names stay distinct.

  5. A leading digit gains an underscore prefix.

  6. A Ruby keyword gains a trailing underscore: if becomes if_.

The original name stays reachable: ORIGINAL_KEYS records the mapping, from_h reads the original spelling, and to_h writes it back. The class a nested object property generates folds the same hex, so the declaring file and the referencing parent agree on one constant; see Names and files.

Two sibling property names can fold onto one accessor ("foo-bar" and "foo_bar" both become foo_bar). The generator keeps the first declaration’s schema, keeps the first original key for load and dump, and logs a warning naming both. Splitting the names in the schema silences it.

Defaults

A schema default becomes the constructor default:

{ "type": "string", "default": "blue" }
def initialize(color: "blue")

from_h leaves defaulted slots untouched when the document omits the key, so an explicit null in the document and an absent key stay distinct: {"color": null} sets nil, an absent key keeps "blue".

Maybe optionals

With optionals: :maybe on a generator instance, every property not listed in required types as Maybe[T] instead of a nilable type, and required properties keep their type.

The Maybe type has two cases, Present(value) and Absent, and carries nullability inside the type: Maybe[T.nilable(Integer)] has three states, Absent for a missing key, Present(nil) for a null, and Present(36) for a value. Loading and dumping follow the document:

The distinction is the content of a partial update: {"age": null} means “clear the age”, an omitted age means “leave it alone”.

Without further options the generator writes its own MyApp::Maybe into the output module, once per output directory, so generated code keeps its property of depending on nothing but sorbet-runtime in the Sorbet formats. The generated module declares interface! and sealed!, so a case over a Maybe checks for exhaustiveness and T.absurd works; value is deliberately not on the interface, so calling it on an Absent does not type check; Absent is one frozen instance.

A project that already has a Maybe passes it with maybe::

Schematrix::Generators::SorbetRuby.new(
  "app/generated",
  "MyApp",
  optionals: :maybe,
  maybe: ::Maybe,
)

The module must answer from(value) and empty, and its instances present? and when_present { |value| }; the generator checks the four at generation time and refuses anything else before any file is written. With a supplied Maybe the generated dump reads through when_present instead of the sealed case, because the generator cannot assume a supplied type is sealed. The dsl_ builders do not accept the option: a builder expresses absence by never calling a setter, which is already the right behaviour.

Partial classes

The partial_ generators (Generators) write a second family where every property wraps in the Maybe type, required ones included, under a Partial module level:

sig do
  params(
    first_name: Maybe[T.nilable(String)],
    age: Maybe[T.nilable(Integer)]
  ).void
end
def initialize(first_name: Maybe.empty, age: Maybe.empty)

Every keyword carries a Maybe.empty default, so an empty partial is one constructor call away. A nested object property types as Maybe[Partial::Address]. A schema default does not materialize from nothing in a partial: the slot starts Absent when the document lacks the key, and wraps the document’s value as Present(value) when it carries it, so an empty patch document dumps as an empty hash.

In class mode, enum slots wrap the shared enum class, for example Maybe[T.nilable(::MyApp::Person::Props::Status)] in Sorbet. The inner type admits null, as it does for other partial slots. Present raw values load as members; present members dump as raw values. Constructor and with signatures use absolute enum references to avoid namespace shadowing. There is no Partial enum class and no second set of members; select an enum writer alongside the partial writer.

Closed shapes

With --closed-shapes, or an explicit "additionalProperties": false, a class has no catch-all: no [], no []=, no @_extra, and from_h drops keys it does not know. See Schema features.

Immutable classes

With immutable: true on a generator instance, or --immutable on the command line, the four data generators write frozen value objects instead of mutable classes. Loading and dumping rules do not change: from_h accepts the same documents and to_h produces the same hashes. For person.schema.json from Getting started, plain_ruby writes:

module MyApp
  class Person
    ORIGINAL_KEYS = {
      "firstName" => :first_name,
      "lastName" => :last_name,
      "age" => :age
    }.freeze

    def initialize(first_name:, last_name:, age: nil, _extra: {})
      @first_name = first_name
      @last_name = last_name
      @age = age
      @_extra = _extra.freeze
      freeze
    end

    attr_reader :first_name, :last_name, :age

    def [](key)
      @_extra[key]
    end

    def with(first_name: @first_name, last_name: @last_name, age: @age, _extra: @_extra)
      self.class.new(first_name: first_name, last_name: last_name, age: age, _extra: _extra)
    end

    def ==(other)
      other.is_a?(self.class) && other.first_name == @first_name &&
        other.last_name == @last_name && other.age == @age &&
        other._extra == @_extra
    end

    alias eql? ==

    def hash
      [self.class, @first_name, @last_name, @age, @_extra].hash
    end

    def self.from_h(hash)
      kwargs = {}
      kwargs[:first_name] = hash["firstName"]
      kwargs[:last_name] = hash["lastName"]
      kwargs[:age] = hash["age"]
      kwargs[:_extra] =
        hash.each_with_object({}) do |(key, value), extras|
          next if ORIGINAL_KEYS.key?(key)

          extras[key] = value
        end
      new(**kwargs)
    end

    def to_h
      result = {}
      result["firstName"] = @first_name unless @first_name.nil?
      result["lastName"] = @last_name unless @last_name.nil?
      result["age"] = @age unless @age.nil?
      @_extra.each { |key, value| result[key] = value }
      result
    end

    protected

    attr_reader :_extra
  end
end

The differences from the default shape:

In use:

person = MyApp::Person.from_h(doc)
older = person.with(age: 37)
older.frozen?                  # => true
person.age                     # => 36
older == person.with(age: 37)  # => true
[person, person.with(age: 36)].uniq.size # => 1

with and hash are generated method names, so a property named with or hash (a checksum field, say) would override one. Like the DSL’s build and to_h, the names are reserved: a property named with or hash gains the trailing underscore treatment, with ORIGINAL_KEYS recording the mapping. _extra joins the reservation because the constructor keyword and the protected reader carry that name. A property named with_, hash_ or _extra_ is untouched, and two siblings can still fold onto one accessor, with the collision warning of Property names.

The option applies to the data generators only. The dsl_ builders are unaffected: a builder is mutable by construction, it accumulates a document. Passing immutable: true to a DSL generator raises ArgumentError.

The Sorbet signatures follow the runtime shape: the readers, with (returns(T.self_type), so subclasses copy as themselves), == and hash all carry signatures, and the []= signature is gone.

File layout

One file per class, in the output directory, at a path that mirrors the class nesting:

app/generated/person.rb
app/generated/complex_object.rb
app/generated/complex_object/props/address.rb

The DSL builders live under a dsl/ prefix and the partial classes under a partial/ prefix, at the same depth, so three families can share one output directory and one module:

app/generated/person.rb                  # MyApp::Person
app/generated/partial/person.rb          # MyApp::Partial::Person
app/generated/dsl/person.rb              # MyApp::Dsl::Person

Each file reopens its enclosing modules and classes, so files load in any order. See Names and files for how names and paths derive from the schema.