Schematist
A general purpose JSON Schema DSL for Ruby with a clean, Rails-inspired API. Emits Draft 2020-12 schemas and depends on nothing.
Formerly RubyLLM::Schema. Trapping a general purpose JSON Schema DSL inside another gem's namespace was a disservice to anyone looking for one, so 1.0 gave it its own name. See Migrating from ruby_llm-schema.
Originally created by Daniel Friis.
Use Cases
JSON Schema is useful wherever Ruby code needs to describe structured data in a portable format.
Some ideal use cases:
- Defining API request and response shapes
- Describing configuration files or structured payloads
- Sharing validation contracts across systems
- Generating structured output schemas for LLM workflows
- Defining structured parameters for RubyLLM tools
Simple Example
class PersonSchema < Schematist::Schema
string :name, description: "Person's full name"
number :age, description: "Age in years", minimum: 0, maximum: 120
boolean :active, required: false
object :address do
string :street
string :city
string :country, required: false
end
array :tags, of: :string, description: "User tags"
array :contacts do
object do
string :email, format: "email"
string :phone, required: false
end
end
any_of :status do
string enum: ["active", "pending", "inactive"]
null
end
end
# Usage
schema = PersonSchema.new
puts schema.to_json
RubyLLM structured output
class PersonSchema < Schematist::Schema
string :name, description: "Person's full name"
integer :age, description: "Person's age in years"
string :city, required: false, description: "City where they live"
end
# Use it natively with RubyLLM
chat = RubyLLM.chat
response = chat.with_schema(PersonSchema)
.ask("Generate a person named Alice who is 30 years old and lives in New York")
# Content stays the raw JSON string, and #parsed gives you the Hash
puts response.content # => "{\"name\":\"Alice\",\"age\":30}"
puts response.parsed # => {"name" => "Alice", "age" => 30}
RubyLLM tools
RubyLLM tools can use schema classes for structured parameters. This is useful when the same argument shape is shared across tools or elsewhere in your app.
class SearchParams < Schematist::Schema
string :query, description: "Search query"
integer :limit, required: false, description: "Maximum results"
end
class SearchDocuments < RubyLLM::Tool
description "Searches internal documents"
parameters SearchParams
def execute(query:, limit: 10)
DocumentSearch.call(query:, limit:)
end
end
For tool-specific arguments, define the schema inline with parameters do ... end.
class Weather < RubyLLM::Tool
description "Gets current weather"
parameters do
string :city, description: "City name"
string :units, enum: %w[celsius fahrenheit], required: false
end
def execute(city:, units: "celsius")
WeatherAPI.current(city:, units:)
end
end
Installation
Add this line to your application's Gemfile:
gem 'schematist'
And then execute:
bundle install
Or install it yourself as:
gem install schematist
Usage
Three approaches for creating schemas:
Class Inheritance
class PersonSchema < Schematist::Schema
string :name, description: "Person's full name"
number :age
boolean :active, required: false
object :address do
string :street
string :city
end
array :tags, of: :string
end
schema = PersonSchema.new
puts schema.to_json
Factory Method
PersonSchema = Schematist::Schema.create do
string :name, description: "Person's full name"
number :age
boolean :active, required: false
object :address do
string :street
string :city
end
array :tags, of: :string
end
schema = PersonSchema.new
puts schema.to_json
Global Helper
require 'schematist'
include Schematist::Helpers
person_schema = schema "PersonData", description: "A person object" do
string :name, description: "Person's full name"
number :age
boolean :active, required: false
object :address do
string :street
string :city
end
array :tags, of: :string
end
puts person_schema.to_json
Schema Property Types
A schema is a collection of properties, which can be of different types. Each type has its own set of properties you can set.
All property types can (along with the required name key) be set with a description and a required flag (default is true).
string :name, description: "Person's full name"
number :age, description: "Person's age", required: false
boolean :is_active, description: "Whether the person is active"
null :placeholder, description: "A placeholder property"
Annotations
Annotations describe a schema for humans and tools. They carry no validation weight.
Supported annotations are title, description, default, examples, deprecated, read_only, and write_only.
Short annotations read well as keyword arguments:
string :email,
title: "Email address",
description: "Primary contact email",
default: "user@example.com",
examples: ["alice@example.com"],
deprecated: false,
read_only: false,
write_only: false
Longer ones read better inside 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
They work at the root of a schema class and inside define too. When the same annotation is given both as a keyword and inside the block, the keyword wins.
⚠️ Please consult the LLM provider documentation for any limitations or restrictions. For example, as of now, OpenAI requires all properties to be required. In that case, you can use the any_of method to make a property optional.
any_of :name, description: "Person's full name" do
string
null
end
Strings
String types support the following properties:
enum: an array of allowed values (e.g.enum: ["on", "off"])const: the single allowed value (e.g.const: "admin")pattern: a regex pattern (e.g.pattern: "\\d+")format: a format string (e.g.format: "email")min_length: the minimum length of the string (e.g.min_length: 3)max_length: the maximum length of the string (e.g.max_length: 10)
Please consult the LLM provider documentation for the available formats and patterns.
string :name, description: "Person's full name"
string :email, format: "email"
string :phone, pattern: "\\d+"
string :status, enum: ["on", "off"]
string :role, const: "admin"
string :code, min_length: 3, max_length: 10
Encoded String Content
Strings that carry encoded content can describe what is inside them.
content_encoding: how the string is encoded (e.g.content_encoding: "base64")content_media_type: the media type of the decoded content (e.g.content_media_type: "application/json")content_schema: a block describing the schema of the decoded content
string :payload, content_encoding: "base64", content_media_type: "application/json" do
content_schema do
object do
string :name
string :email
end
end
end
Numbers
Number and integer types support the following properties:
enum: an array of allowed numeric values (e.g.enum: [0, 1, 2])const: the single allowed value (e.g.const: 1)format: a format string (e.g.format: "int64")multiple_of: a multiple of the number (e.g.multiple_of: 0.01)minimum: the minimum value of the number (e.g.minimum: 0)maximum: the maximum value of the number (e.g.maximum: 100)greater_than: an exclusive minimum (e.g.greater_than: 0)less_than: an exclusive maximum (e.g.less_than: 100)
number :price, minimum: 0, maximum: 100
number :score, greater_than: 0, less_than: 100
number :amount, multiple_of: 0.01
integer :level, enum: [0, 1, 2]
Booleans
boolean :is_active
boolean :accepted_terms, const: true
boolean :flag, enum: [true]
Booleans support const and enum.
Null
null :placeholder
null :nothing, enum: [nil]
Nulls support enum.
Arrays
An array is a list of items. You can set the type of the items in the array with the of option or by passing a block with the object method.
An array can have a min_items and max_items option to set the minimum and maximum number of items in the array.
array :tags, of: :string # Array of strings
array :scores, of: :number # Array of numbers
array :items, min_items: 1, max_items: 10 # Array with size constraints
array :items do # Array of objects
object do
string :name
number :price
end
end
array :tags, of: :string, unique: true # No duplicate items
array :scores do # At least one score of 10 or more
integer
contains min: 1 do
integer minimum: 10
end
end
Tuples
A tuple is an array where each position has its own schema. It emits prefixItems, and by default it is exactly as long as its prefix.
tuple :coordinates do
number description: "Latitude"
number description: "Longitude"
end
Give it somewhere for the rest to go and it stops being fixed length. of: types the tail, unevaluated_items: closes it off after the prefix, and an explicit max_items: sets its own bound.
tuple :event, of: :string do # prefixItems, then strings
string
integer
end
tuple :pair, unevaluated_items: false do
string
string
end
Objects
Objects types expect a block with the properties of the object.
object :user do
string :name
number :age
end
object :settings, description: "User preferences" do
boolean :notifications
string :theme, enum: ["light", "dark"]
end
Object Key Constraints
Objects can constrain how many properties they carry, and what their keys look like.
min_properties/max_properties: how many properties the object may havekeys: a schema every property name must match, as JSON SchemapropertyNameskeys_matching: a schema for the properties whose names match a pattern, as JSON SchemapatternProperties
object :metadata, min_properties: 1, max_properties: 10 do
keys do
string pattern: "^[a-z_]+$"
end
keys_matching(/^x-/) do
string
end
keys_matching(/^count_/) do
integer minimum: 0
end
end
keys and keys_matching also work at the root of a schema class and inside define.
Union Types (anyOf)
Union types are a way to specify that a property can be one of several types.
any_of :value do
string
number
null
end
any_of :identifier do
string description: "Username"
number description: "User ID"
end
Composition (oneOf, allOf, not)
one_of matches exactly one of the given schemas, all_of matches all of them, and none_of matches none of them.
one_of :payment do
object do
string :card_number
end
object do
string :iban
end
end
all_of :account do
object do
string :id
end
object do
string :status
end
end
none_of :status do
string enum: ["deleted"]
end
none_of with a single schema emits not: { ... }. With several, it emits not: { anyOf: [...] }.
Unevaluated Properties and Items
unevaluated_properties and unevaluated_items constrain what is left over after composition, references, and conditionals have had their say. They are most useful on all_of, where additional_properties cannot see across the branches.
all_of :person, unevaluated_properties: false do
object do
string :name
end
object do
integer :age
end
end
object :profile, of: :person, unevaluated_properties: false
array :values, of: :integer, unevaluated_items: false
Runtime Values
Any schema value can be a proc, resolved when the schema is rendered. One schema class then produces a different document per instance, which is what you want when an enum comes from the database.
class RoleSchema < Schematist::Schema
description -> { "Roles available to #{@account.name}" }
string :role, enum: -> { @account.roles.pluck(:name) }
def initialize(account:)
super()
@account = account
end
end
RoleSchema.new(account: account).to_json_schema
A proc with no arguments is evaluated in the instance's context, so it can read instance variables. A proc that takes one argument receives the schema instance instead.
Boolean and Raw Schemas
JSON Schema allows true and false in place of a schema object: true accepts every value, false accepts none. Inside a block, any_schema and no_schema emit them.
any_of :value do
any_schema
string
end
When you need a keyword this DSL doesn't cover, raw emits a fragment verbatim.
raw :role, { type: "string", const: "admin" }
any_of :value do
raw type: "string", const: "admin"
integer
end
Schemas That Aren't Objects
A type with a name declares a property. Without a name it declares what the schema itself is. That is how a root, or a definition, becomes something other than an object.
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
The same rule applies inside define, so a definition can be any schema:
define :status do
string enum: %w[draft sent] # a reusable string
end
define :address do
string :street # named, so an object with properties
end
Objects also take their keywords at the root:
class Metadata < Schematist::Schema
string :name
min_properties 1
max_properties 10
unevaluated_properties false
end
Schema Definitions and References
You can define sub-schemas and reference them in other schemas, or reference the root schema to generate recursive schemas.
class MySchema < Schematist::Schema
define :location do
string :latitude
string :longitude
end
# Using a reference in an array
array :coordinates, of: :location
# Using a reference in an object via the `reference` option
object :home_location, reference: :location
# Using a reference in an object via block
object :user do
reference :location
end
# Using a reference to the root schema
object :ui_schema do
string :element, enum: ["input", "button"]
string :label
object :sub_schema, reference: :root
end
end
Core Keywords
Use core keywords when a schema or subschema needs an identifier, anchor, comment, dynamic reference, or vocabulary declaration.
class Node < Schematist::Schema
id "https://example.com/schemas/node"
comment "Internal note"
dynamic_anchor "node"
vocabulary "https://json-schema.org/draft/2020-12/vocab/core" => true
define :address do
anchor "address"
string :street
end
object :child do
dynamic_ref "#node"
end
end
dynamic_ref and dynamic_anchor are emitted verbatim. Their recursive resolution is the validator's job; this gem does not expand or interpret them.
Nested Schemas
You can embed existing schema classes directly within objects or arrays for reusable schema composition.
class PersonSchema < Schematist::Schema
string :name
integer :age
end
class CompanySchema < Schematist::Schema
# Using 'of' parameter
object :ceo, of: PersonSchema
array :employees, of: PersonSchema
# Using Schema.new in block
object :founder do
PersonSchema.new
end
end
schema = CompanySchema.new
schema.to_json_schema
# =>
# {
# "$schema":"https://json-schema.org/draft/2020-12/schema",
# "title":"CompanySchema",
# "type":"object",
# "properties":{
# "ceo":{
# "type":"object",
# "properties":{
# "name":{"type":"string"},
# "age":{"type":"integer"}
# },
# "required":["name","age"],
# "additionalProperties":false
# },
# "employees":{
# "type":"array",
# "items":{
# "type":"object",
# "properties":{
# "name":{"type":"string"},
# "age":{"type":"integer"}
# },
# "required":["name","age"],
# "additionalProperties":false
# }
# },
# "founder":{
# "type":"object",
# "properties":{
# "name":{"type":"string"},
# "age":{"type":"integer"}
# },
# "required":["name","age"],
# "additionalProperties":false
# }
# },
# "required":["ceo","employees","founder"],
# "additionalProperties":false
# }
Dependencies
Use requires: inline or dependent block to express that the presence of one property requires others. Maps to dependentRequired (Draft 2019-09) and dependentSchemas (Draft 2019-09). Check your provider's documentation for compatibility.
class PaymentSchema < Schematist::Schema
string :name
number :credit_card, required: false, requires: %i[billing_address cvv]
string :billing_address, required: false
string :cvv, required: false
end
Use a dependent block when you also need validations. This upgrades the output to dependentSchemas:
dependent :credit_card do
requires :billing_address
validates :billing_address, type: :string, min_length: 1
end
Conditionals
Use given to add JSON Schema if/then/else (Draft 7) rules. Condition values are automatically coerced: strings → const, arrays → enum, regexps → pattern, hashes → raw schema.
class OrderSchema < Schematist::Schema
string :status, enum: ["pending", "shipped", "cancelled"]
string :tracking_number, required: false
string :cancellation_reason, required: false
given status: "shipped" do
requires :tracking_number
end
given status: "cancelled" do
requires :cancellation_reason
validates :cancellation_reason, type: :string, min_length: 1
end
end
validates supports: type:, not_value:, min_length:, max_length:, pattern: (string or regexp), enum:, const:, minimum:, maximum:.
Use otherwise for an else branch:
given domestic: true do
requires :state
otherwise do
requires :country
end
end
Conditions propagate through nested schemas via of:.
A branch is a schema, so anything you can write in a schema you can write in a branch. requires and validates stay as shorthands for the two common cases.
given kind: "business" do
requires :vat_id
object :tax_details do
string :vat_number
end
array :filings, of: :string
otherwise do
validates :vat_id, type: :string
end
end
given matches on property values. Pass a schema explicitly when the condition is something else:
given({ required: %w[tax_id] }) do
requires :summary
end
JSON Output
to_json_schema returns a Draft 2020-12 JSON Schema document with string keys, ready to hand to any JSON Schema validator.
schema = PersonSchema.new
schema.to_json_schema
# => {
# "$schema" => "https://json-schema.org/draft/2020-12/schema",
# "title" => "PersonSchema",
# "type" => "object",
# "properties" => { ... },
# "required" => [...],
# "additionalProperties" => false
# }
puts schema.to_json # Pretty JSON string of the same document
The schema name maps to title. Provider-only keys are not part of the document. strict was an OpenAI response_format flag rather than a JSON Schema keyword, so it has been removed. Set it where you build the request.
Migrating from ruby_llm-schema
RubyLLM::Schema is now Schematist. Update the gem, then the constants:
gem 'schematist' # was: gem 'ruby_llm-schema'
class PersonSchema < Schematist::Schema # was: RubyLLM::Schema
end
include Schematist::Helpers # was: RubyLLM::Helpers
Errors moved up a level with the rename: Schematist::ValidationError, not RubyLLM::Schema::ValidationError. strict is gone; see below.
Migrating from the provider envelope
to_json_schema returns the schema document itself. It used to return {name:, description:, schema:, strict:}, the shape OpenAI's response_format expects, and building that belongs in whatever talks to the provider.
If you were reaching into [:schema] to get at the document, drop the digging. Note the keys are strings, not symbols:
schema.to_json_schema[:schema][:properties] # before
schema.to_json_schema["properties"] # now
If you need the envelope for a provider that expects it, build it where you send it:
{
name: "PersonSchema",
schema: PersonSchema.new.to_json_schema,
strict: true
}
License
The gem is available as open source under the terms of the MIT License.