I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL.
Schematist is a general purpose JSON Schema DSL that emits Draft 2020-12 schemas. Describe an API payload, a config file, a contract between two services, or the structured output you want back from a model. Trapping that inside another gem’s namespace was a disservice to anyone looking for a great JSON Schema DSL, so it got its own name.
gem 'schematist'
It Emits Actual JSON Schema #
This is the breaking change.
to_json_schema
used to return this:
{ name: "PersonSchema", description: nil, schema: { type: "object", ... }, strict: true }
That’s not a JSON Schema. It’s OpenAI’s response_format
envelope, with the actual schema buried one level down under a symbol key. Every consumer that wasn’t OpenAI had to dig it out, and anyone who wanted to hand the result to a validator had to know which part was real.
Now you get the document:
class Invoice < Schematist::Schema
title "Invoice"
description "A billing document"
string :id, pattern: "^inv_", title: "Invoice ID"
number :total, greater_than: 0, description: "Amount due"
string :currency, const: "EUR"
string :status, enum: %w[draft sent paid], default: "draft"
end
Invoice.new.to_json_schema
String keys, $schema
declared, no provider keys. Use it with JSON.generate
unchanged and any Draft 2020-12 validator will take it.
strict
went with it. It’s an OpenAI request flag, not a JSON Schema keyword, and a schema library has no business knowing OpenAI exists. Set it where you build the request.
Full Draft 2020-12 Coverage #
The old gem covered the basics: types, enum
, required
, string and numeric bounds, nested objects and arrays, $defs
and $ref
, if
/then
/else
. Schematist covers the whole vocabulary.
Composition. allOf
, oneOf
, and not
join anyOf
:
one_of :method do
object { string :card_number }
object { string :iban }
end
all_of :account, unevaluated_properties: false do
object { string :id }
object { string :status }
end
none_of :state do
string enum: ["deleted"]
end
unevaluated_properties
is the one that makes allOf
usable in practice. additionalProperties
can’t see across composition branches; unevaluatedProperties
can.
Object keys. Constrain how many properties an object has, what its keys look like, and what the values behind a key pattern must be:
object :metadata, min_properties: 1, max_properties: 10 do
keys { string pattern: "^[a-z_]+$" } # propertyNames
keys_matching(/^x-/) { string } # patternProperties
end
Arrays. uniqueItems
, fixed-length tuples via prefixItems
, and contains
with its bounds:
array :tags, of: :string, unique: true
tuple :period do
string format: "date"
string format: "date"
end
array :scores do
integer
contains(min: 1) { integer minimum: 10 } # at least one score of 10 or more
end
Annotations. title
, description
, default
, examples
, deprecated
, read_only
, write_only
. Short ones read well as keyword arguments; longer ones read better in the block, where they annotate the enclosing schema:
object :account do
title "Account"
description "Billing account metadata used for invoices."
examples [{ id: "acct_123", status: "active" }]
string :id
string :status
end
Encoded content. For strings that carry something else inside them:
string :payload, content_encoding: "base64", content_media_type: "application/json" do
content_schema do
object { string :name }
end
end
Core keywords. $id
, $anchor
, $comment
, $dynamicAnchor
, $dynamicRef
, $vocabulary
, at the root or on any subschema. They’re passed straight through. Resolving a dynamic reference is the validator’s job, not ours.
Also new: const
on every primitive, and greater_than
/ less_than
for exclusiveMinimum
/ exclusiveMaximum
. I picked the Ruby-sounding names over the JSON Schema ones on purpose. You’re writing Ruby.
Values That Aren’t Known Until Render Time #
You define a schema class once, at boot. The allowed values often aren’t known until a request comes in.
Any value can be a proc now, resolved when the document is rendered:
class RoleSchema < Schematist::Schema
string :role, enum: -> { @account.roles.pluck(:name) }
def initialize(account:)
super()
@account = account
end
end
RoleSchema.new(account: account).to_json_schema
A zero-argument proc is evaluated in the instance’s context, so it can read instance variables. A proc that takes one argument gets the schema instance instead. One class, a different document per instance.
Escape Hatches #
Covering the spec isn’t the same as guessing everything you’ll want to put in a document, so there are two ways out.
JSON Schema allows true
and false
in place of a schema object. true
accepts anything, false
accepts nothing:
any_of :value do
any_schema
string
end
And raw
drops a fragment in as-is, for a vendor extension or anything else the DSL has no opinion about:
raw :vendor, { "type" => "object", "x-vendor" => true }
A Schema Doesn’t Have To Be an Object #
Most schemas describe an object, so that’s the default. But JSON Schema doesn’t care. A schema can be an array, a union, a string, or a pointer somewhere else, and the root of a document is just a schema like any other.
So: a type with a name declares a property. Without a name, it declares what the schema itself is.
class Tags < Schematist::Schema
array of: :string, unique: true # the whole schema is an array
end
class Id < Schematist::Schema
one_of do # the whole schema is a choice
string
integer
end
end
class Person < Schematist::Schema
raw({ "$ref" => "https://example.com/person.json" })
end
It works inside define
too, so a reusable definition can be a string with a pattern or a shared enum, not just an object:
define :status do
string enum: %w[draft sent paid]
end
A conditional branch is a schema too, so it can ask for a nested object instead of a flat list of fields:
given kind: "business" do
requires :vat_id
object :tax_details do
string :vat_number
end
end
No Runtime Dependencies #
Schematist depends on nothing.
Migrating #
gem 'schematist' # was: gem 'ruby_llm-schema'
class Person < Schematist::Schema # was: RubyLLM::Schema
end
Errors moved up a level: Schematist::ValidationError
, not RubyLLM::Schema::ValidationError
. Schematist::Helpers
replaces RubyLLM::Helpers
.
If you were reaching into [:schema]
to get at the document, stop. to_json_schema
returns it directly now, with string keys. If you need the provider wrapper, build it where you send the request:
{ name: "Invoice", schema: Invoice.new.to_json_schema, strict: true }
There’s a final ruby_llm-schema
1.0.0 that depends on Schematist and aliases the old constants, so RubyLLM::Schema
keeps resolving while you move. It warns on load and it’s the last release of that name.
RubyLLM 2.0 will depend on Schematist, so structured output will get a lot more powerful.
Use It #
bundle add schematist
Schematist was always a JSON Schema DSL. Now it has the name to match.