{"slug": "rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020", "title": "RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage", "summary": "RubyLLM::Schema has been renamed to Schematist, a standalone JSON Schema DSL for Ruby that emits Draft 2020-12 schemas with full vocabulary coverage, including composition keywords, object key constraints, array features, and annotations. The breaking change removes the OpenAI-specific response_format envelope, so to_json_schema now returns a pure JSON Schema document with string keys and a declared $schema, usable by any Draft 2020-12 validator. The gem is available as 'schematist' on GitHub.", "body_md": "I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL.\n\n[Schematist](https://github.com/crmne/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.\n\n```\ngem 'schematist'\n```\n\n## It Emits Actual JSON Schema\n\nThis is the breaking change.\n\n`to_json_schema`\n\nused to return this:\n\n```\n{ name: \"PersonSchema\", description: nil, schema: { type: \"object\", ... }, strict: true }\n```\n\nThat’s not a JSON Schema. It’s OpenAI’s `response_format`\n\nenvelope, 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.\n\nNow you get the document:\n\n```\nclass Invoice < Schematist::Schema\n  title \"Invoice\"\n  description \"A billing document\"\n\n  string :id, pattern: \"^inv_\", title: \"Invoice ID\"\n  number :total, greater_than: 0, description: \"Amount due\"\n  string :currency, const: \"EUR\"\n  string :status, enum: %w[draft sent paid], default: \"draft\"\nend\n\nInvoice.new.to_json_schema\n# => {\n#   \"$schema\" => \"https://json-schema.org/draft/2020-12/schema\",\n#   \"title\" => \"Invoice\",\n#   \"description\" => \"A billing document\",\n#   \"type\" => \"object\",\n#   \"properties\" => {\n#     \"id\" => { \"type\" => \"string\", \"pattern\" => \"^inv_\", \"title\" => \"Invoice ID\" },\n#     \"total\" => { \"type\" => \"number\", \"description\" => \"Amount due\", \"exclusiveMinimum\" => 0 },\n#     ...\n#   },\n#   \"required\" => [\"id\", \"total\", \"currency\", \"status\"],\n#   \"additionalProperties\" => false\n# }\n```\n\nString keys, `$schema`\n\ndeclared, no provider keys. Use it with `JSON.generate`\n\nunchanged and any Draft 2020-12 validator will take it.\n\n`strict`\n\nwent 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.\n\n## Full Draft 2020-12 Coverage\n\nThe old gem covered the basics: types, `enum`\n\n, `required`\n\n, string and numeric bounds, nested objects and arrays, `$defs`\n\nand `$ref`\n\n, `if`\n\n/`then`\n\n/`else`\n\n. [Schematist](https://github.com/crmne/schematist) covers the whole vocabulary.\n\n**Composition.** `allOf`\n\n, `oneOf`\n\n, and `not`\n\njoin `anyOf`\n\n:\n\n```\none_of :method do\n  object { string :card_number }\n  object { string :iban }\nend\n\nall_of :account, unevaluated_properties: false do\n  object { string :id }\n  object { string :status }\nend\n\nnone_of :state do\n  string enum: [\"deleted\"]\nend\n```\n\n`unevaluated_properties`\n\nis the one that makes `allOf`\n\nusable in practice. `additionalProperties`\n\ncan’t see across composition branches; `unevaluatedProperties`\n\ncan.\n\n**Object keys.** Constrain how many properties an object has, what its keys look like, and what the values behind a key pattern must be:\n\n```\nobject :metadata, min_properties: 1, max_properties: 10 do\n  keys { string pattern: \"^[a-z_]+$\" }     # propertyNames\n  keys_matching(/^x-/) { string }          # patternProperties\nend\n```\n\n**Arrays.** `uniqueItems`\n\n, fixed-length tuples via `prefixItems`\n\n, and `contains`\n\nwith its bounds:\n\n```\narray :tags, of: :string, unique: true\n\ntuple :period do\n  string format: \"date\"\n  string format: \"date\"\nend\n\narray :scores do\n  integer\n  contains(min: 1) { integer minimum: 10 }   # at least one score of 10 or more\nend\n```\n\n**Annotations.** `title`\n\n, `description`\n\n, `default`\n\n, `examples`\n\n, `deprecated`\n\n, `read_only`\n\n, `write_only`\n\n. Short ones read well as keyword arguments; longer ones read better in the block, where they annotate the enclosing schema:\n\n```\nobject :account do\n  title \"Account\"\n  description \"Billing account metadata used for invoices.\"\n  examples [{ id: \"acct_123\", status: \"active\" }]\n\n  string :id\n  string :status\nend\n```\n\n**Encoded content.** For strings that carry something else inside them:\n\n```\nstring :payload, content_encoding: \"base64\", content_media_type: \"application/json\" do\n  content_schema do\n    object { string :name }\n  end\nend\n```\n\n**Core keywords.** `$id`\n\n, `$anchor`\n\n, `$comment`\n\n, `$dynamicAnchor`\n\n, `$dynamicRef`\n\n, `$vocabulary`\n\n, at the root or on any subschema. They’re passed straight through. Resolving a dynamic reference is the validator’s job, not ours.\n\nAlso new: `const`\n\non every primitive, and `greater_than`\n\n/ `less_than`\n\nfor `exclusiveMinimum`\n\n/ `exclusiveMaximum`\n\n. I picked the Ruby-sounding names over the JSON Schema ones on purpose. You’re writing Ruby.\n\n## Values That Aren’t Known Until Render Time\n\nYou define a schema class once, at boot. The allowed values often aren’t known until a request comes in.\n\nAny value can be a proc now, resolved when the document is rendered:\n\n``` php\nclass RoleSchema < Schematist::Schema\n  string :role, enum: -> { @account.roles.pluck(:name) }\n\n  def initialize(account:)\n    super()\n    @account = account\n  end\nend\n\nRoleSchema.new(account: account).to_json_schema\n```\n\nA 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.\n\n## Escape Hatches\n\nCovering the spec isn’t the same as guessing everything you’ll want to put in a document, so there are two ways out.\n\nJSON Schema allows `true`\n\nand `false`\n\nin place of a schema object. `true`\n\naccepts anything, `false`\n\naccepts nothing:\n\n```\nany_of :value do\n  any_schema\n  string\nend\n```\n\nAnd `raw`\n\ndrops a fragment in as-is, for a vendor extension or anything else the DSL has no opinion about:\n\n``` js\nraw :vendor, { \"type\" => \"object\", \"x-vendor\" => true }\n```\n\n## A Schema Doesn’t Have To Be an Object\n\nMost 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.\n\nSo: a type with a name declares a property. Without a name, it declares what the schema itself is.\n\n```\nclass Tags < Schematist::Schema\n  array of: :string, unique: true       # the whole schema is an array\nend\n\nclass Id < Schematist::Schema\n  one_of do                             # the whole schema is a choice\n    string\n    integer\n  end\nend\n\nclass Person < Schematist::Schema\n  raw({ \"$ref\" => \"https://example.com/person.json\" })\nend\n```\n\nIt works inside `define`\n\ntoo, so a reusable definition can be a string with a pattern or a shared enum, not just an object:\n\n```\ndefine :status do\n  string enum: %w[draft sent paid]\nend\n```\n\nA conditional branch is a schema too, so it can ask for a nested object instead of a flat list of fields:\n\n```\ngiven kind: \"business\" do\n  requires :vat_id\n\n  object :tax_details do\n    string :vat_number\n  end\nend\n```\n\n## No Runtime Dependencies\n\n[Schematist](https://github.com/crmne/schematist) depends on nothing.\n\n## Migrating\n\n```\ngem 'schematist'                         # was: gem 'ruby_llm-schema'\n\nclass Person < Schematist::Schema        # was: RubyLLM::Schema\nend\n```\n\nErrors moved up a level: `Schematist::ValidationError`\n\n, not `RubyLLM::Schema::ValidationError`\n\n. `Schematist::Helpers`\n\nreplaces `RubyLLM::Helpers`\n\n.\n\nIf you were reaching into `[:schema]`\n\nto get at the document, stop. `to_json_schema`\n\nreturns it directly now, with string keys. If you need the provider wrapper, build it where you send the request:\n\n```\n{ name: \"Invoice\", schema: Invoice.new.to_json_schema, strict: true }\n```\n\nThere’s a final `ruby_llm-schema`\n\n1.0.0 that depends on [Schematist](https://github.com/crmne/schematist) and aliases the old constants, so `RubyLLM::Schema`\n\nkeeps resolving while you move. It warns on load and it’s the last release of that name.\n\n[RubyLLM](https://rubyllm.com) 2.0 will depend on Schematist, so structured output will get a lot more powerful.\n\n## Use It\n\n```\nbundle add schematist\n```\n\n[Schematist](https://github.com/crmne/schematist) was always a JSON Schema DSL. Now it has the name to match.", "url": "https://wpnews.pro/news/rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020", "canonical_source": "https://paolino.me/schematist/", "published_at": "2026-08-11 00:00:00+00:00", "updated_at": "2026-08-21 11:15:20.211595+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Schematist", "RubyLLM", "OpenAI", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020", "markdown": "https://wpnews.pro/news/rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020.md", "text": "https://wpnews.pro/news/rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020.txt", "jsonld": "https://wpnews.pro/news/rubyllm-schema-is-now-schematist-a-json-schema-dsl-for-ruby-with-full-draft-2020.jsonld"}}