Rich Types
Signatures can use Sorbet enums, structs, unions, arrays, hashes, and nullable fields. DSPy.rb converts those declarations into an output schema and coerces the returned values at runtime.
Choose a Rich Type
DSPy.rb supports:
- Enums: Constrained value sets with T::Enum
- Structs: Complex objects with T::Struct
- Union Types: Multiple possible types with T.any()
- Collections: Arrays and hashes of typed elements
- Nullable Fields: Values that may be
nil, declared with T.nilable - Schema generation: DSPy.rb renders the declared output types for the configured JSON strategy
Enum Types
Basic Enums
class Sentiment < T::Enum
enums do
Positive = new('positive')
Negative = new('negative')
Neutral = new('neutral')
end
end
class ClassifyText < DSPy::Signature
description "Classify text sentiment"
input do
const :text, String
end
output do
const :sentiment, Sentiment
const :confidence, Float
end
end
# Usage
classifier = DSPy::Predict.new(ClassifyText)
result = classifier.call(text: "I love this product!")
puts result.sentiment.serialize # => "positive"
String Enum Values
class Priority < T::Enum
enums do
Low = new('low')
Medium = new('medium')
High = new('high')
Critical = new('critical')
end
end
class TicketClassifier < DSPy::Signature
description "Classify support ticket priority"
input do
const :ticket_content, String
end
output do
const :priority, Priority
const :reasoning, String
end
end
Multiple Enum Fields
class Category < T::Enum
enums do
Technical = new('technical')
Billing = new('billing')
Account = new('account')
end
end
class Status < T::Enum
enums do
Open = new('open')
InProgress = new('in_progress')
Resolved = new('resolved')
end
end
class TicketAnalysis < DSPy::Signature
description "Analyze support ticket"
input do
const :content, String
end
output do
const :category, Category
const :priority, Priority
const :status, Status
end
end
Struct Types
Basic Structs
class ContactInfo < T::Struct
const :name, String
const :email, String
const :phone, T.nilable(String)
end
class ExtractContact < DSPy::Signature
description "Extract contact information from text"
input do
const :text, String
end
output do
const :contact, ContactInfo
const :confidence, Float
end
end
# Usage
extractor = DSPy::Predict.new(ExtractContact)
result = extractor.call(text: "John Doe - john@example.com - 555-1234")
# Access struct fields
puts result.contact.name # => "John Doe"
puts result.contact.email # => "john@example.com"
puts result.contact.phone # => "555-1234"
Nested Structs
class Address < T::Struct
const :street, String
const :city, String
const :state, String
const :zip_code, String
end
class Person < T::Struct
const :name, String
const :age, Integer
const :address, Address
end
class ExtractPersonInfo < DSPy::Signature
description "Extract detailed person information"
input do
const :text, String
end
output do
const :person, Person
end
end
Collection Types
Arrays
class ExtractKeywords < DSPy::Signature
description "Extract keywords from text"
input do
const :text, String
end
output do
const :keywords, T::Array[String]
const :count, Integer
end
end
# Usage
extractor = DSPy::Predict.new(ExtractKeywords)
result = extractor.call(text: "Machine learning and artificial intelligence...")
puts result.keywords # => ["machine learning", "artificial intelligence", ...]
Arrays of Structs
Arrays can contain custom T::Struct types. When a returned JSON array contains compatible hashes, prediction coercion converts each hash to the declared T::Struct.
class Product < T::Struct
const :name, String
const :price, Float
const :category, String
end
class ExtractProducts < DSPy::Signature
description "Extract product information from text"
input do
const :text, String
end
output do
const :products, T::Array[Product]
const :total_found, Integer
end
end
# Usage
extractor = DSPy::Predict.new(ExtractProducts)
result = extractor.call(text: "We have iPhone 15 for $999 and Samsung Galaxy for $799...")
# DSPy automatically converts the JSON response to Product structs
result.products.each do |product|
# Each product is a proper Product struct instance
puts "#{product.name} - $#{product.price} (#{product.category})"
end
Complex Struct Arrays
You can also use more complex structs with nested types:
class Citation < T::Struct
const :title, String
const :author, String
const :year, Integer
const :relevance, Float
const :tags, T::Array[String]
end
class ResearchSynthesis < DSPy::Signature
description "Synthesize research papers on a topic"
input do
const :query, String
const :max_results, Integer
end
output do
const :citations, T::Array[Citation]
const :summary, String
const :key_findings, T::Array[String]
end
end
# The LLM returns JSON like:
# {
# "citations": [
# {"title": "...", "author": "...", "year": 2023, "relevance": 0.95, "tags": ["ML", "NLP"]},
# {"title": "...", "author": "...", "year": 2022, "relevance": 0.87, "tags": ["AI"]}
# ],
# "summary": "...",
# "key_findings": ["...", "..."]
# }
# DSPy automatically converts each citation hash to a Citation struct
synthesizer = DSPy::Predict.new(ResearchSynthesis)
result = synthesizer.call(query: "transformer architectures", max_results: 5)
result.citations.each do |citation|
# citation is a Citation struct, not a hash
puts "#{citation.title} by #{citation.author} (#{citation.year})"
puts "Relevance: #{(citation.relevance * 100).round}%"
puts "Tags: #{citation.tags.join(', ')}"
end
Hash Types
class AnalyzeMetrics < DSPy::Signature
description "Analyze text and return metrics"
input do
const :text, String
end
output do
const :metrics, T::Hash[String, Float]
const :summary, String
end
end
# Results in metrics like:
# { "readability" => 0.8, "sentiment_score" => 0.6, "complexity" => 0.4 }
Nullable and Omittable Types
Nullable Fields
In a DSPy::Signature, T.nilable allows nil; it does not remove the field from the schema’s required list. Add a default when omission is valid. See Nullable and Omittable Fields for the canonical rules and the standalone T::Struct boundary.
class ProductInfo < T::Struct
const :name, String
const :price, T.nilable(Float) # Value may be nil
const :description, T.nilable(String) # Value may be nil
const :in_stock, T::Boolean
end
class ExtractProductInfo < DSPy::Signature
description "Extract product information, handling missing data"
input do
const :text, String
end
output do
const :product, ProductInfo
const :confidence, Float
end
end
# Handles cases where price or description might not be available
Complex Nullable Structures
class Review < T::Struct
const :rating, Integer
const :comment, String
const :reviewer_name, T.nilable(String)
const :verified_purchase, T::Boolean
end
class ExtractReviews < DSPy::Signature
description "Extract product reviews from text"
input do
const :text, String
end
output do
const :reviews, T::Array[Review]
const :average_rating, T.nilable(Float)
end
end
Union Types
Use Sorbet’s T.any() syntax when one field may return one of several declared types.
Basic Union Types
You can use T.any() to specify that a field can be one of several types:
class FlexibleAnalysis < DSPy::Signature
description "Analyze data that could be numeric or textual"
input do
const :data, String
end
output do
# Can be either a float (for numeric data) or a string (for categories)
const :result, T.any(Float, String)
const :result_type, String # "numeric" or "categorical"
end
end
# Usage
analyzer = DSPy::Predict.new(FlexibleAnalysis)
# Numeric result
result1 = analyzer.call(data: "The average score is 85.5")
puts result1.result # => 85.5 (Float)
puts result1.result_type # => "numeric"
# String result
result2 = analyzer.call(data: "The category is premium")
puts result2.result # => "premium" (String)
puts result2.result_type # => "categorical"
Union Types with Structs (Single-Field Unions)
Union types can combine several struct types. DSPy.rb adds a _type discriminator to each struct schema so the returned object can be coerced to the correct type.
# NEW in v0.11.0: Single-field union types - no discriminator needed!
# Define specific action structs (no type field required)
module TaskActions
class CreateTask < T::Struct
const :title, String
const :description, String
const :priority, T.enum([:low, :medium, :high])
const :due_date, T.nilable(String)
end
class UpdateTask < T::Struct
const :task_id, String
const :updates, T::Hash[Symbol, T.untyped]
const :updated_fields, T::Array[String]
end
class DeleteTask < T::Struct
const :task_id, String
const :reason, T.nilable(String)
const :archive, T::Boolean, default: true
end
end
# Simple signature with single union field
class TaskActionSignature < DSPy::Signature
description "Determine the appropriate task action from user input"
input do
const :user_request, String
const :context, T.nilable(String)
end
output do
# Just one field! DSPy automatically handles type detection
const :action, T.any(
TaskActions::CreateTask,
TaskActions::UpdateTask,
TaskActions::DeleteTask
)
const :reasoning, String
end
end
# Usage with automatic type conversion
processor = DSPy::Predict.new(TaskActionSignature)
result = processor.call(
user_request: "Create a new task for reviewing the Q4 report",
context: "Project management workspace"
)
# DSPy automatically detects the type from the _type field
puts result.action.class # => TaskActions::CreateTask
puts result.action.title # => "Review Q4 Report"
puts result.action.priority # => :high
puts result.reasoning # => "User wants to create a task for Q4 review"
# Pattern matching works beautifully
case result.action
when TaskActions::CreateTask
puts "Creating task: #{result.action.title}"
when TaskActions::UpdateTask
puts "Updating task: #{result.action.task_id}"
when TaskActions::DeleteTask
puts "Deleting task: #{result.action.task_id}"
end
Follow Discriminator-Based Coercion
When DSPy.rb receives a response from the LLM for a union type field:
_typefield: DSPy adds the struct’s class name as_typein each struct schema- Type Detection: When deserializing, DSPy looks for the
_typefield in the response - Conversion: It converts the hash to the struct selected by
_type
Declaring the union in the signature triggers these steps; no separate discriminator configuration is required.
Behind the Scenes
When the LLM returns:
{
"action": {
"_type": "CreateTask",
"title": "Review Q4 Report",
"description": "Analyze quarterly results",
"priority": "high",
"due_date": null
},
"reasoning": "User wants to create a task for Q4 review"
}
DSPy then:
- Sees
_type: "CreateTask" - Finds
TaskActions::CreateTaskin the union types - Creates a proper
CreateTaskstruct instance
Union Types in Arrays
You can also use union types within arrays for heterogeneous collections:
class Event < T::Struct
abstract!
const :timestamp, String
const :user_id, String
end
class LoginEvent < Event
const :ip_address, String
const :success, T::Boolean
end
class PurchaseEvent < Event
const :product_id, String
const :amount, Float
const :currency, String
end
class PageViewEvent < Event
const :page_url, String
const :referrer, T.nilable(String)
end
class ExtractEvents < DSPy::Signature
description "Extract different types of events from logs"
input do
const :log_text, String
end
output do
const :events, T::Array[T.any(LoginEvent, PurchaseEvent, PageViewEvent)]
const :event_count, Integer
end
end
# DSPy will automatically convert each array element to the appropriate event type
Best Practices for Union Types
- Single-field unions (v0.11.0+): Use one
T.any()field so DSPy can select the type from its discriminator:
# Good: Single union field
output do
const :result, T.any(SuccessResult, ErrorResult, PendingResult)
end
# Avoid: Manual discriminator pattern (pre-v0.11.0)
output do
const :result_type, ResultType # No longer needed!
const :result_data, T.any(...)
end
-
Bound the alternatives: Start with two to four types, then inspect the generated schema and evaluate returned values before adding more.
-
Meaningful Struct Names: Use clear struct names as they become the
_typevalue:
# Good: Clear type names
class CreateUserAction < T::Struct
class UpdateUserAction < T::Struct
class DeleteUserAction < T::Struct
# These become _type values: "CreateUserAction", "UpdateUserAction", etc.
- Document Expected Types: In your signature description, clearly indicate when and why different types might be returned:
class AnalyzeContent < DSPy::Signature
description <<~DESC
Analyze content and return:
- Numeric score (0-100) for measurable content
- Descriptive category for qualitative content
- Null if content cannot be analyzed
DESC
output do
const :analysis, T.any(Integer, String, NilClass)
end
end
Tip: Use T::Array[X], default: [] when omission should produce an empty collection and nil is not meaningful. This is a data-modeling choice, not a universal provider requirement. Check the generated schema for nested structs: a Ruby default does not necessarily remove an array property from the schema’s required list.
Coerce Results with DSPy::Prediction
Since v0.9.0, DSPy::Prediction has used the signature’s output schema to coerce compatible JSON values into declared Ruby types when a DSPy module builds the prediction.
Follow Prediction Coercion
- Schema Awareness: DSPy::Prediction uses the signature’s output schema to understand expected types
- Recursive Conversion: Nested hashes are converted to their corresponding T::Struct types
- Enum deserialization: Compatible string values are converted to
T::Enuminstances - Array Handling: Arrays of structs are converted element by element
- Default Values: Missing fields use their default values from the struct definition
- Graceful Fallback: If conversion fails, the original hash is preserved
Inspect a Converted Prediction
class AnalysisResult < DSPy::Signature
class Priority < T::Enum
enums do
Low = new('low')
Medium = new('medium')
High = new('high')
end
end
class Finding < T::Struct
const :description, String
const :priority, Priority
const :tags, T::Array[String], default: []
end
output do
const :findings, T::Array[Finding]
const :summary, String
end
end
# When the LLM returns:
# {
# "findings": [
# {"description": "Security issue", "priority": "high"},
# {"description": "Performance issue", "priority": "low", "tags": ["optimization"]}
# ],
# "summary": "Found 2 issues"
# }
analyzer = DSPy::Predict.new(AnalysisResult)
result = analyzer.call(input: "analyze this code")
# Automatic conversions:
result.findings # => Array of Finding structs (not hashes!)
result.findings.first.priority # => Priority::High (not "high" string!)
result.findings.first.tags # => [] (default value applied)
result.findings.last.tags # => ["optimization"]
Conversion Features
1. Deep Nesting Support
class Company < T::Struct
class Department < T::Struct
class Team < T::Struct
const :name, String
const :size, Integer
end
const :name, String
const :teams, T::Array[Team]
end
const :name, String
const :departments, T::Array[Department]
end
# Nested structures are converted recursively
# Hash -> Company -> Department -> Team
2. Union Type Handling
# With discriminator fields
output do
const :action_type, ActionEnum # Discriminator
const :details, T.any(CreateAction, UpdateAction, DeleteAction)
end
# DSPy automatically selects the correct struct type based on action_type
3. Edge Cases
- Missing Fields: Use explicit defaults when omission is part of the contract
- Extra Fields: Ignored during conversion
- Type Mismatches: Falls back to original value
- Invalid Enums: Raises KeyError (handle appropriately)
Performance Considerations
- Conversion happens once when creating the Prediction object
- Deeply nested structures (5+ levels) may impact performance
- Large arrays are converted element by element
- Consider flattening very complex structures
Limitations
- Complex Union Resolution: Without discriminators, union type selection is based on field matching
- Circular References: Not supported
- Custom Deserializers: Use T::Struct’s built-in serialization
- Very Deep Nesting: May hit recursion limits or performance issues
Working with Complex Results
Accessing Nested Data
result = extractor.call(text: input_text)
# Access struct fields
person = result.person
puts "Name: #{person.name}"
puts "Address: #{person.address.street}, #{person.address.city}"
# Work with arrays
result.keywords.each_with_index do |keyword, i|
puts "#{i+1}. #{keyword}"
end
# Process hash results
result.metrics.each do |metric, value|
puts "#{metric}: #{value.round(2)}"
end
Validation and Error Handling
result = extractor.call(text: input_text)
# Check for nil values
if result.contact.phone
puts "Phone: #{result.contact.phone}"
else
puts "No phone number provided"
end
# Validate array contents
if result.products.any?
puts "Found #{result.products.size} products"
result.products.each do |product|
puts "- #{product.name}: $#{product.price}"
end
else
puts "No products found"
end
Schema Format Options
DSPy.rb renders declared rich types in either of two schema formats:
JSON Schema (Default)
# The signature automatically creates schemas for the LLM
signature = ClassifyText.new
schema = signature.schema
# Schema includes type constraints:
# {
# "input": {
# "text": {"type": "string"}
# },
# "output": {
# "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
# "confidence": {"type": "number"}
# }
# }
BAML Schema Format (New in v0.13.0)
For nested types, BAML can render a shorter schema than JSON Schema. Measure the difference for the signature and provider you use:
# Configure BAML format globally
DSPy.configure do |c|
c.lm = DSPy::LM.new(
'openai/gpt-4o-mini',
api_key: ENV['OPENAI_API_KEY'],
schema_format: :baml
)
end
# Complex nested type rendered in BAML
class CompanyAnalysis < DSPy::Signature
class Department < T::Struct
const :name, String
const :employee_count, Integer
const :budget, Float
end
output do
const :departments, T::Array[Department]
const :total_employees, Integer
end
end
# BAML representation (compact):
# class CompanyAnalysisOutput {
# departments Department[]
# total_employees int
# }
#
# class Department {
# name string
# employee_count int
# budget float
# }
# Compare this representation with the generated JSON Schema.
Schema length does not establish output quality. Compare token usage, validation failures, and task metrics before choosing a format.
See the Schema Formats section in Signatures for detailed comparison.
Design Types for Coercion
1. Use Descriptive Names
# Good: Clear purpose and constraints
class TaskPriority < T::Enum
enums do
Low = new('low')
Medium = new('medium')
High = new('high')
Urgent = new('urgent')
end
end
# Good: Descriptive struct fields
class CustomerFeedback < T::Struct
const :satisfaction_score, Integer
const :main_complaint, T.nilable(String)
const :would_recommend, T::Boolean
end
2. Handle Missing Data Gracefully
class ExtractCompanyInfo < DSPy::Signature
description "Extract company information, handling incomplete data"
input do
const :text, String
end
output do
const :company_name, String
const :industry, T.nilable(String)
const :employee_count, T.nilable(Integer)
const :founded_year, T.nilable(Integer)
const :confidence, Float
end
end
# Usage with error handling
result = extractor.call(text: company_description)
company_info = {
name: result.company_name,
industry: result.industry || "Unknown",
size: result.employee_count || "Not specified",
age: result.founded_year ? Date.current.year - result.founded_year : nil
}
3. Use Validation in Your Logic
def process_extraction_result(result)
# Validate required fields
return nil unless result.contact.name.present?
return nil unless result.contact.email.present?
# Process nullable values carefully
contact_info = {
name: result.contact.name,
email: result.contact.email
}
contact_info[:phone] = result.contact.phone if result.contact.phone
contact_info
end
4. Design for LLM Understanding
# Use clear, unambiguous enum values
class ResponseType < T::Enum
enums do
Positive = new('positive') # Clear
Negative = new('negative') # Clear
Neutral = new('neutral') # Clear
# Avoid: Mixed = new('mixed') # Ambiguous
end
end
# Use meaningful struct field names
class EmailClassification < T::Struct
const :is_spam, T::Boolean # Clear boolean
const :spam_confidence, Float # Clear confidence measure
const :primary_topic, String # Clear categorization
end
Bound Nesting and Provider Risk
Nesting Depth Limitations
DSPy.rb has practical limits on nested struct complexity:
✅ Recommended Nesting (1-2 levels):
class Address < T::Struct
const :street, String
const :city, String
const :state, String
end
class Person < T::Struct
const :name, String
const :address, Address # 2 levels total - works reliably
end
⚠️ Deep Nesting (3+ levels) - Use with Caution:
# This creates increasingly complex JSON schemas that may:
# - Trigger OpenAI depth validation warnings (>5 levels)
# - Have type coercion issues with deeply nested T::Struct objects
# - Reduce LLM accuracy due to schema complexity
class Level3 < T::Struct
const :level4, Level4
end
class Level2 < T::Struct
const :level3, Level3
end
class Level1 < T::Struct
const :level2, Level2 # 4+ levels - may fail
end
❌ Avoid Excessive Nesting (5+ levels):
- JSON schema generation works but creates complex schemas
- Type coercion may return Hash objects instead of proper T::Struct instances
- OpenAI structured outputs may reject schemas exceeding depth limits
- LLMs struggle with deeply nested output requirements
Performance Considerations
Schema Caching: DSPy.rb caches rendered JSON schemas for repeated use:
# First call generates schema
result1 = predictor.call(input: "text")
# Second call uses cached schema (faster)
result2 = predictor.call(input: "more text")
Provider Optimization: Different providers handle rich types differently:
- OpenAI structured outputs: Verify the selected model and SDK against its current depth and schema limits.
- Anthropic: JSON extraction supports the nested examples shown here; evaluate the same shape with the model you deploy.
- Enhanced prompting: This fallback avoids native schema parameters; prefer shallower structures and measure validation failures.
Troubleshooting Rich Types
Type Coercion Issues: If you get Hash objects instead of T::Struct instances:
# Check if the issue is with deep nesting
class SimpleStruct < T::Struct
const :field, String
end
# Test with a simple struct first
# If it works, the issue is likely nesting depth
Schema Validation: Check schema depth warnings:
schema = YourSignature.output_json_schema
issues = DSPy::OpenAI::LM::SchemaConverter.validate_compatibility(schema)
puts issues # Shows depth and complexity warnings
Alternative Approaches: Instead of deep nesting, consider:
- Flattening complex structures
- Using separate API calls for complex data
- Breaking down into multiple simpler signatures