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 validators -
valid?/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: 1
-
Use a richer type system than "string/integer/object" EasyTalk supports Sorbet-style types and composition:
-
T.nilable(Type)for nullable fields -
T::Array[Type]for typed arrays -
T::Tuple[Type1, Type2, ...]for fixed-position typed arrays T::Boolean-
T::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
- Required vs optional vs nullable
- Nested models
- Tuple arrays
- Composition (AnyOf / OneOf / AllOf)
- Validations
- Automatic validations
- Per-model validation control
- Per-property validation control
- Validation adapters
- Error formatting
- Schema-only mode
- RubyLLM Integration
- Configuration highlights
- Advanced topics
- JSON Schema drafts,
$id, and$ref - Additional properties with types
- Object-level constraints
- Custom type builders
- JSON Schema drafts,
- Known limitations
- Contributing
- License
Installation
Requirements
- Ruby 3.2+
Add to your Gemfile:
gem "easy_talk"Then:
bundle installQuick start
| EasyTalk Model | Generated JSON Schema |
|---|---|
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 |
{
"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::ErrorsProperty 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 properties -
pattern_properties- Schema for properties matching regex patterns -
dependent_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
endBy 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
endNested 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 # => AddressNested models inside arrays work too:
class Order
include EasyTalk::Model
define_schema do
property :line_items, T::Array[Address], min_items: 1
end
endTuple 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 |
|---|---|
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]
) |
{
"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
endControlling 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]
endTuple 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
endValidations
Automatic validations (default)
EasyTalk can generate ActiveModel validations from constraints:
EasyTalk.configure do |config|
config.auto_validations = true
endDisable globally:
EasyTalk.configure do |config|
config.auto_validations = false
endWhen 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
endPer-model validation control
class LegacyModel
include EasyTalk::Model
define_schema(validations: false) do
property :data, String, min_length: 1 # no validation generated
end
endPer-property validation control
class User
include EasyTalk::Model
define_schema do
property :name, String, min_length: 2
property :legacy_field, String, validate: false
end
endValidation adapters
EasyTalk uses a pluggable adapter system:
EasyTalk.configure do |config|
config.validation_adapter = :active_model # default
# config.validation_adapter = :none # disable validation generation
endError formatting
Instance helpers:
user.validation_errors_flat
user.validation_errors_json_pointer
user.validation_errors_rfc7807
user.validation_errors_jsonapiFormat 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
endSchema-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? # => NoMethodErrorUse 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
endAdvanced 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
endExternal schema references
Use external URIs in $ref for modular, reusable schemas:
| EasyTalk Model | Generated JSON Schema |
|---|---|
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 |
{
"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
endPer-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
endAdditional 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 |
|---|---|
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 |
{
"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
endObject-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
endDependent 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
endCustom 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