EasyTalk
Ruby library for defining structured data contracts that generate JSON Schema and (optionally) runtime validations from the same definition.
Think “Pydantic-style ergonomics” for Ruby, with first-class JSON Schema output.
Why EasyTalk?
You can hand-write JSON Schema, then hand-write validations, then hand-write error responses… and eventually you’ll ship a bug where those three disagree.
EasyTalk makes the schema definition the single source of truth, so you can:
Define once, use everywhere
One Ruby DSL gives you:json_schemafor docs, OpenAPI, LLM tools, and external validatorsvalid?/errors(when usingEasyTalk::Model) for runtime validation
Stop arguing with JSON Schema’s verbosity
Express constraints in Ruby where you already live:property :email, String, format: "email" property :age, Integer, minimum: 18 property :tags, T::Array[String], min_items: 1Use a richer type system than "string/integer/object" EasyTalk supports Sorbet-style types and composition:
T.nilable(Type)for nullable fieldsT::Array[Type]for typed arraysT::Tuple[Type1, Type2, ...]for fixed-position typed arraysT::BooleanT::AnyOf,T::OneOf,T::AllOffor schema composition
Get validations for free (when you want them)
Withauto_validationsenabled (default), schema constraints generate ActiveModel validations—including nested models, even inside arrays.Make API errors consistent
Format validation errors as:- flat lists
- JSON Pointer
- RFC 7807 problem details
- JSON:API error objects
LLM tool/function schemas without a second schema layer Use the same contract to generate JSON Schema for function/tool calling. See RubyLLM Integration.
EasyTalk is for teams who want their data contracts to be correct, reusable, and boring (the good kind of boring).
Table of Contents
- Installation
- Quick start
- Property constraints
- Core concepts
- Validations
- Error formatting
- Schema-only mode
- RubyLLM Integration
- Configuration highlights
- Advanced topics
- Known limitations
- Contributing
- License
Installation
Requirements
- Ruby 3.2+
Add to your Gemfile:
gem "easy_talk"
Then:
bundle install
Quick start
| EasyTalk Model | Generated JSON Schema |
|---|---|
| ```ruby require "easy_talk" class User include EasyTalk::Model define_schema do title "User" description "A user of the system" property :id, String property :name, String, min_length: 2 property :email, String, format: "email" property :age, Integer, minimum: 18 end end ``` | ```json { "type": "object", "title": "User", "description": "A user of the system", "properties": { "id": { "type": "string" }, "name": { "type": "string", "minLength": 2 }, "email": { "type": "string", "format": "email" }, "age": { "type": "integer", "minimum": 18 } }, "required": ["id", "name", "email", "age"] } ``` |
User.json_schema # => Ruby Hash (JSON Schema)
user = User.new(name: "A") # invalid: min_length is 2
user.valid? # => false
user.errors # => ActiveModel::Errors
Property constraints
| Constraint | Applies to | Example |
|---|---|---|
min_length / max_length |
String | property :name, String, min_length: 2, max_length: 50 |
minimum / maximum |
Integer, Float | property :age, Integer, minimum: 18, maximum: 120 |
format |
String | property :email, String, format: "email" |
pattern |
String | property :zip, String, pattern: '^\d{5}$' |
enum |
Any | property :status, String, enum: ["active", "inactive"] |
min_items / max_items |
Array, Tuple | property :tags, T::Array[String], min_items: 1 |
unique_items |
Array, Tuple | property :ids, T::Array[Integer], unique_items: true |
additional_items |
Tuple | property :coords, T::Tuple[Float, Float], additional_items: false |
optional |
Any | property :nickname, String, optional: true |
default |
Any | property :role, String, default: "user" |
description |
Any | property :name, String, description: "Full name" |
title |
Any | property :name, String, title: "User Name" |
Object-level constraints (applied in define_schema block):
min_properties/max_properties- Minimum/maximum number of propertiespattern_properties- Schema for properties matching regex patternsdependent_required- Conditional property requirements
When auto_validations is enabled (default), these constraints automatically generate corresponding ActiveModel validations.
Core concepts
Required vs optional vs nullable (don't get tricked)
JSON Schema distinguishes:
- Optional: property may be omitted (not in
required) - Nullable: property may be
null(type includes"null")
EasyTalk mirrors that precisely:
class Profile
include EasyTalk::Model
define_schema do
# required, not nullable
property :name, String
# required, nullable (must exist, may be null)
property :age, T.nilable(Integer)
# optional, not nullable (may be omitted, but cannot be null if present)
property :nickname, String, optional: true
# optional + nullable (may be omitted OR null)
property :bio, T.nilable(String), optional: true
# or, equivalently:
nullable_optional_property :website, String
end
end
By default, T.nilable(Type) makes a field nullable but still required.
If you want “nilable implies optional” behavior globally:
EasyTalk.configure do |config|
config.nilable_is_optional = true
end
Nested models (and automatic instantiation)
Define nested objects as separate classes, then reference them:
class Address
include EasyTalk::Model
define_schema do
property :street, String
property :city, String
end
end
class User
include EasyTalk::Model
define_schema do
property :name, String
property :address, Address
end
end
user = User.new(
name: "John",
address: { street: "123 Main St", city: "Boston" } # Hash becomes Address automatically
)
user.address.class # => Address
Nested models inside arrays work too:
class Order
include EasyTalk::Model
define_schema do
property :line_items, T::Array[Address], min_items: 1
end
end
Tuple arrays (fixed-position types)
Use T::Tuple for arrays where each position has a specific type (e.g., coordinates, CSV rows, database records):
| EasyTalk Model | Generated JSON Schema |
|---|---|
| ```ruby class GeoLocation include EasyTalk::Model define_schema do property :name, String # Fixed: [latitude, longitude] property :coordinates, T::Tuple[Float, Float] end end location = GeoLocation.new( name: 'Office', coordinates: [40.7128, -74.0060] ) ``` | ```json { "properties": { "coordinates": { "type": "array", "items": [ { "type": "number" }, { "type": "number" } ] } } } ``` |
Mixed-type tuples:
class DataRow
include EasyTalk::Model
define_schema do
# Fixed: [name, age, active]
property :row, T::Tuple[String, Integer, T::Boolean]
end
end
Controlling extra items:
define_schema do
# Reject extra items (strict tuple)
property :rgb, T::Tuple[Integer, Integer, Integer], additional_items: false
# Allow extra items of specific type
property :header_values, T::Tuple[String], additional_items: Integer
# Allow any extra items (default)
property :flexible, T::Tuple[String, Integer]
end
Tuple validation:
model = GeoLocation.new(coordinates: [40.7, "invalid"])
model.valid? # => false
model.errors[:coordinates]
# => ["item at index 1 must be a Float"]
Composition (AnyOf / OneOf / AllOf)
class ProductA
include EasyTalk::Model
define_schema do
property :sku, String
property :weight, Float
end
end
class ProductB
include EasyTalk::Model
define_schema do
property :sku, String
property :color, String
end
end
class Cart
include EasyTalk::Model
define_schema do
property :items, T::Array[T::AnyOf[ProductA, ProductB]]
end
end
Validations
Automatic validations (default)
EasyTalk can generate ActiveModel validations from constraints:
EasyTalk.configure do |config|
config.auto_validations = true
end
Disable globally:
EasyTalk.configure do |config|
config.auto_validations = false
end
When auto validations are off, you can still write validations manually:
class User
include EasyTalk::Model
validates :name, presence: true, length: { minimum: 2 }
define_schema do
property :name, String, min_length: 2
end
end
Per-model validation control
class LegacyModel
include EasyTalk::Model
define_schema(validations: false) do
property :data, String, min_length: 1 # no validation generated
end
end
Per-property validation control
class User
include EasyTalk::Model
define_schema do
property :name, String, min_length: 2
property :legacy_field, String, validate: false
end
end
Validation adapters
EasyTalk uses a pluggable adapter system:
EasyTalk.configure do |config|
config.validation_adapter = :active_model # default
# config.validation_adapter = :none # disable validation generation
end
Error formatting
Instance helpers:
user.validation_errors_flat
user.validation_errors_json_pointer
user.validation_errors_rfc7807
user.validation_errors_jsonapi
Format directly:
EasyTalk::ErrorFormatter.format(user.errors, format: :rfc7807, title: "User Validation Failed")
Global defaults:
EasyTalk.configure do |config|
config.default_error_format = :rfc7807
config.error_type_base_uri = "https://api.example.com/errors"
config.include_error_codes = true
end
Schema-only mode
If you want schema generation and attribute accessors without ActiveModel validation:
class ApiContract
include EasyTalk::Schema
define_schema do
title "API Contract"
property :name, String, min_length: 2
property :age, Integer, minimum: 0
end
end
ApiContract.json_schema
contract = ApiContract.new(name: "Test", age: 25)
# No validations available:
# contract.valid? # => NoMethodError
Use this for documentation, OpenAPI generation, or when validation happens elsewhere.
RubyLLM Integration
EasyTalk integrates seamlessly with RubyLLM for structured outputs and tool definitions.
Structured Outputs
Use any EasyTalk model with RubyLLM's with_schema to get structured JSON responses:
class Recipe
include EasyTalk::Model
define_schema do
description "A cooking recipe"
property :name, String, description: "Name of the dish"
property :ingredients, T::Array[String], description: "List of ingredients"
property :prep_time_minutes, Integer, description: "Preparation time in minutes"
end
end
chat = RubyLLM.chat.with_schema(Recipe)
response = chat.ask("Give me a simple pasta recipe")
# RubyLLM returns parsed JSON - instantiate with EasyTalk model
recipe = Recipe.new(response.content)
recipe.name # => "Spaghetti Aglio e Olio"
recipe.ingredients # => ["spaghetti", "garlic", "olive oil", ...]
Tools
Create LLM tools by inheriting from RubyLLM::Tool and including EasyTalk::Model:
class Weather < RubyLLM::Tool
include EasyTalk::Model
define_schema do
description 'Gets current weather for a location'
property :latitude, String, description: 'Latitude (e.g., 52.5200)'
property :longitude, String, description: 'Longitude (e.g., 13.4050)'
end
def execute(latitude:, longitude:)
# Fetch weather data from API...
{ temperature: 22, conditions: "sunny" }
end
end
chat = RubyLLM.chat.with_tool(Weather)
response = chat.ask("What's the weather in Berlin?")
This pattern gives you:
- Full access to
RubyLLM::Toolfeatures (halt,call, etc.) - EasyTalk's schema DSL for parameter definitions
- Automatic JSON Schema generation for the LLM
Configuration highlights
EasyTalk.configure do |config|
# Schema behavior
config.default_additional_properties = false
config.nilable_is_optional = false
config.schema_version = :none
config.schema_id = nil
config.use_refs = false
config.base_schema_uri = nil # Base URI for auto-generating $id
config.auto_generate_ids = false # Auto-generate $id from base_schema_uri
config.prefer_external_refs = false # Use external URI in $ref when available
config.property_naming_strategy = :identity # :snake_case, :camel_case, :pascal_case
# Validations
config.auto_validations = true
config.validation_adapter = :active_model
# Error formatting
config.default_error_format = :flat # :flat, :json_pointer, :rfc7807, :jsonapi
config.error_type_base_uri = "about:blank"
config.include_error_codes = true
end
Advanced topics
For more detailed documentation, see the full API reference on RubyDoc.
JSON Schema drafts, $id, and $ref
EasyTalk can emit $schema for multiple drafts (Draft-04 through 2020-12), supports $id, and can use $ref/$defs for reusable definitions:
EasyTalk.configure do |config|
config.schema_version = :draft202012
config.schema_id = "https://example.com/schemas/user.json"
config.use_refs = true # Use $ref/$defs for nested models
end
External schema references
Use external URIs in $ref for modular, reusable schemas:
| EasyTalk Model | Generated JSON Schema |
|---|---|
| ```ruby EasyTalk.configure do |config| config.use_refs = true config.prefer_external_refs = true config.base_schema_uri = 'https://example.com/schemas' config.auto_generate_ids = true end class Address include EasyTalk::Model define_schema do property :street, String property :city, String end end class Customer include EasyTalk::Model define_schema do property :name, String property :address, Address end end Customer.json_schema ``` | ```json { "properties": { "address": { "$ref": "https://example.com/schemas/address" } }, "$defs": { "Address": { "$id": "https://example.com/schemas/address", "properties": { "street": { "type": "string" }, "city": { "type": "string" } } } } } ``` |
Explicit schema IDs:
class Address
include EasyTalk::Model
define_schema do
schema_id 'https://example.com/schemas/address'
property :street, String
end
end
Per-property ref control:
class Customer
include EasyTalk::Model
define_schema do
property :address, Address, ref: false # Inline instead of ref
property :billing, Address # Uses ref (global setting)
end
end
Additional properties with types
Beyond boolean values, additional_properties now supports type constraints for dynamic properties:
class Config
include EasyTalk::Model
define_schema do
property :name, String
# Allow any string-typed additional properties
additional_properties String
end
end
config = Config.new(name: 'app')
config.label = 'Production' # Dynamic property
config.as_json
# => { 'name' => 'app', 'label' => 'Production' }
With constraints:
| EasyTalk Model | Generated JSON Schema |
|---|---|
| ```ruby class StrictConfig include EasyTalk::Model define_schema do property :id, Integer # Integer values between 0 and 100 only additional_properties Integer, minimum: 0, maximum: 100 end end StrictConfig.json_schema ``` | ```json { "properties": { "id": { "type": "integer" } }, "additionalProperties": { "type": "integer", "minimum": 0, "maximum": 100 } } ``` |
Nested models as additional properties:
class Person
include EasyTalk::Model
define_schema do
property :name, String
additional_properties Address # All additional properties must be Address objects
end
end
Object-level constraints
Apply schema-wide constraints to limit or validate object structure:
class StrictObject
include EasyTalk::Model
define_schema do
property :required1, String
property :required2, String
property :optional1, String, optional: true
property :optional2, String, optional: true
# Require at least 2 properties
min_properties 2
# Allow at most 3 properties
max_properties 3
end
end
obj = StrictObject.new(required1: 'a')
obj.valid? # => false (only 1 property, needs at least 2)
Pattern properties:
class DynamicConfig
include EasyTalk::Model
define_schema do
property :name, String
# Properties matching /^env_/ must be strings
pattern_properties(
'^env_' => { type: 'string' }
)
end
end
Dependent required:
class ShippingInfo
include EasyTalk::Model
define_schema do
property :name, String
property :credit_card, String, optional: true
property :billing_address, String, optional: true
# If credit_card is present, billing_address is required
dependent_required(
'credit_card' => ['billing_address']
)
end
end
Custom type builders
Register custom types with their own schema builders:
EasyTalk.configure do |config|
config.register_type(Money, MoneySchemaBuilder)
end
# Or directly:
EasyTalk::Builders::Registry.register(Money, MoneySchemaBuilder)
See the Custom Type Builders documentation for details on creating builders.
Known limitations
EasyTalk aims to produce broadly compatible JSON Schema, but:
- Some draft-specific keywords/features may require manual schema tweaks
- Custom formats are limited (extend via custom builders when needed)
- Extremely complex composition can outgrow “auto validations” and may need manual validations or external schema validators
Contributing
- Run
bin/setup - Run specs:
bundle exec rake spec - Run lint:
bundle exec rubocop
Bug reports and PRs welcome.
License
MIT