Structure
Structure your data
Turn unruly hashes into clean Ruby Data objects with type coercion.
# Before: Hash drilling
user_name = response["user"]["name"]
user_age = response["user"]["age"].to_i
user_active = response["user"]["is_active"] == "true"
# After: Clean, typed objects
user.name # => "Alice" (String)
user.age # => 25 (Integer)
user.active? # => trueBuilt on Ruby Data for immutability, pattern matching, and all the other good stuff. Zero dependencies.
Installation
Add to your Gemfile:
gem "structure"Usage
The Basics
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:active, :boolean)
end
user = User.parse({
"name" => "Alice",
"age" => "25",
"active" => "true"
})
user.name # => "Alice" (String)
user.age # => 25 (Integer)
user.active # => true (TrueClass)
user.active? # => true (predicate method)Type Coercion
Uses Ruby's built-in coercion methods to convert data:
Product = Structure.new do
attribute(:title, String) # Uses String(val)
attribute(:price, Float) # Uses Float(val)
attribute(:quantity, Integer) # Uses Integer(val)
attribute(:available, :boolean) # Custom boolean logic
end
product = Product.parse({
"title" => 123,
"price" => "19.99",
"quantity" => "5",
"available" => "1"
})
product.title # => "123"
product.price # => 19.99
product.quantity # => 5
product.available # => trueKey Mapping
Clean up gnarly keys:
Person = Structure.new do
attribute(:name, String, from: "full_name")
attribute(:active, :boolean, from: "is_active")
end
person = Person.parse({
"full_name" => "Bob Smith",
"is_active" => "true"
})
person.name # => "Bob Smith"
person.active? # => trueOptional Attributes
Structure wraps Data classes. All attributes are required when creating instances, even if their value is nil.
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
end
User.parse(name: "Alice", age: 30) # Works
User.parse(name: nil, age: nil) # Works, nil values allowed
User.parse(name: "Alice") # ArgumentError: missing keyword: :ageUse attribute? to make attributes truly optional. The key can then be omitted entirely.
User = Structure.new do
attribute(:name, String)
attribute?(:age, Integer)
end
# Now you can omit the optional attribute
User.parse(name: "Bob") # Works, age defaults to nil
# You still must provide regular attributes
User.parse(age: 10) # ArgumentError: missing keyword: :nameNon-Nullable Attributes
By default, attributes can be nil. Use null: false to enforce GraphQL-style non-null semantics:
User = Structure.new do
attribute?(:name, String, null: false) # optional key, but must be non-null when present
attribute?(:bio, String) # optional key, can be null
end
User.parse(name: "Alice") # works
User.parse(bio: nil) # works, bio can be null
User.parse(name: nil) # ArgumentError: cannot be null: :nameThis makes null: false perfect for parsing GraphQL responses where you need to specify that a field must be non-null when present.
Default Values
Handle missing data:
Config = Structure.new do
attribute(:timeout, Integer, default: 30)
attribute(:debug, :boolean, default: false)
end
config = Config.parse({}) # Empty data
config.timeout # => 30
config.debug # => falseArray Types
Arrays with automatic element coercion:
Order = Structure.new do
attribute(:items, [String])
attribute(:quantities, [Integer])
attribute(:flags, [:boolean])
end
order = Order.parse({
"items" => [123, 456, "hello"],
"quantities" => ["1", "2", 3.5],
"flags" => ["true", 0, 1, "false"]
})
order.items # => ["123", "456", "hello"]
order.quantities # => [1, 2, 3]
order.flags # => [true, false, true, false]Nested Objects
Compose structures for complex data:
Address = Structure.new do
attribute(:street, String)
attribute(:city, String)
end
User = Structure.new do
attribute(:name, String)
attribute(:address, Address)
end
user = User.parse({
"name" => "Alice",
"address" => {
"street" => "123 Main St",
"city" => "Boston"
}
})
user.name # => "Alice"
user.address.street # => "123 Main St"
user.address.city # => "Boston"Arrays of Objects
Combine array syntax with nested objects:
Tag = Structure.new do
attribute(:name, String)
attribute(:color, String)
end
Product = Structure.new do
attribute(:title, String)
attribute(:tags, [Tag])
end
product = Product.parse({
"title" => "Laptop",
"tags" => [
{ "name" => "electronics", "color" => "blue" },
{ "name" => "computers", "color" => "green" }
]
})
product.title # => "Laptop"
product.tags.first.name # => "electronics"Lazy Resolution
To handle circular dependencies between classes, you can use string class names that are resolved lazily:
module MyApp
Order = Structure.new do
attribute(:id, String)
attribute(:items, ["OrderItem"]) # String resolved lazily
attribute(:customer, "Customer") # String resolved lazily
end
OrderItem = Structure.new do
attribute(:name, String)
attribute(:order, "Order") # Circular reference back to Order
end
Customer = Structure.new do
attribute(:name, String)
attribute(:orders, ["Order"]) # Circular reference to Order
end
end
# Works despite circular dependencies
order = MyApp::Order.parse({
"id" => "123",
"customer" => { "name" => "Alice" },
"items" => [{ "name" => "Widget" }]
})
order.customer.name # => "Alice"
order.items.first.name # => "Widget"Custom Transformations
When you need custom logic:
Order = Structure.new do
attribute :price do |value|
Money.new(value["amount"], value["currency"])
end
end
order = Order.parse({
"price" => { "amount" => "29.99", "currency" => "USD" }
})
order.price # => #<Money:0x... @amount="29.99", @currency="USD">Boolean Conversion
Structure follows Rails-style boolean conversion:
Truthy values: true, 1, "1", "t", "T", "true", "TRUE", "on", "ON"
Falsy values: Everything else (including false, 0, "0", "false", "", nil)
User = Structure.new do
attribute(:active, :boolean)
end
User.parse(active: "true").active # => true
User.parse(active: "1").active # => true
User.parse(active: "false").active # => false
User.parse(active: "0").active # => false
User.parse(active: "").active # => falseSupported Types
Structure supports Ruby's kernel coercion methods like String(val), Integer(val), Float(val), etc., plus:
-
:boolean- Custom Rails-style boolean conversion -
[Type]- Arrays with element coercion - Custom classes with
.parsemethod - Ruby standard library classes with
.parse, including:-
Date- Parses date strings -
Time- Parses various time formats -
URI- Parses URLs into URI objects
-
Event = Structure.new do
attribute(:name, String)
attribute(:date, Date)
attribute(:starts_at, Time)
attribute(:website, URI)
end
event = Event.parse({
"name" => "RubyConf",
"date" => "2024-12-25",
"starts_at" => "2024-12-25T09:00:00-05:00",
"website" => "https://rubyconf.org"
})
event.date # => #<Date: 2024-12-25>
event.starts_at # => 2024-12-25 09:00:00 -0500
event.website # => #<URI::HTTPS https://rubyconf.org>Custom Types
The type system is flexible. Any object that responds to .call (procs, lambdas) or .parse (classes) can be used as a type:
# Using a lambda for simple transformations
UppercaseString = ->(val) { val.to_s.upcase }
# Using a class with .parse for complex types
class Money
def self.parse(data)
return nil unless data
amount = data.is_a?(Hash) ? data['amount'] : data
new(amount.to_f)
end
def initialize(amount)
@amount = amount
end
attr_reader :amount
end
Product = Structure.new do
attribute :name, UppercaseString
attribute :price, Money
end
product = Product.parse({
"name" => "widget",
"price" => { "amount" => "19.99" }
})
product.name # => "WIDGET"
product.price.amount # => 19.99Self-Referential Types
Build tree structures and other self-referential data:
Tree = Structure.new do
attribute(:id, Integer)
attribute(:name, String)
attribute(:children, [:self], default: [])
end
tree = Tree.parse({
"id" => 1,
"name" => "Electronics",
"children" => [
{ "id" => 2, "name" => "Computers" },
{ "id" => 3, "name" => "Phones", "children" => [
{ "id" => 4, "name" => "Smartphones" }
]}
]
})
tree.name # => "Electronics"
tree.children.first.name # => "Computers"
tree.children[1].children.first.name # => "Smartphones"Use :self for single references or [:self] for arrays of self-references. Perfect for modeling hierarchical data like navigation menus, comment threads, or organizational charts.
After Parse Callbacks
Add validation or post-processing logic that runs after parsing:
Order = Structure.new do
attribute(:order_id, String)
attribute(:total, Float)
after_parse do |order|
raise "Order ID is required" if order.order_id.nil?
raise "Total must be positive" if order.total && order.total <= 0
end
end
# Raises error for invalid data
Order.parse(total: -10) # => RuntimeError: Total must be positive
# Works fine with valid data
order = Order.parse(order_id: "123", total: 99.99)
order.order_id # => "123"The after_parse callback receives the parsed instance and runs after all attributes have been coerced. Any exception raised prevents the instance from being returned.
Custom Methods
Define instance and class methods directly in the Structure block, just like Data.define:
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:active, :boolean)
# Instance methods
def adult?
age >= 18
end
def greeting
"Hello, I'm #{name}"
end
def status
active ? "online" : "offline"
end
# Class methods
def self.create_guest
parse(name: "Guest", age: 0, active: false)
end
end
user = User.parse(name: "Alice", age: 25, active: true)
user.adult? # => true
user.greeting # => "Hello, I'm Alice"
user.status # => "online"
guest = User.create_guest
guest.name # => "Guest"
guest.adult? # => falseCustom methods work seamlessly with all Structure features including type coercion, key mapping, defaults, optional attributes, nested structures, and arrays:
Product = Structure.new do
attribute(:name, String)
attribute(:price, Float)
attribute(:tags, [String])
attribute?(:discount, Float)
def discounted_price
return price unless discount
price * (1 - discount)
end
def has_tag?(tag)
tags.include?(tag)
end
def self.categories
["electronics", "books", "clothing"]
end
end
product = Product.parse(
name: "Laptop",
price: "999.99",
tags: ["electronics", "computers"],
discount: "0.1"
)
product.discounted_price # => 899.991
product.has_tag?("electronics") # => true
Product.categories # => ["electronics", "books", "clothing"]RBS Type Signatures
Generate RBS type signatures for your Structure classes:
require 'structure/rbs'
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:tags, [String])
end
# Generate RBS content
Structure::RBS.emit(User)
# => class User < Data
# def self.new: (name: String?, age: Integer?, tags: Array[String]?) -> instance
# def self.parse: (?(Hash[String | Symbol, untyped]), **untyped) -> instance
# attr_reader name: String?
# attr_reader age: Integer?
# attr_reader tags: Array[String]?
# ...
# end
# Write RBS to file
Structure::RBS.write(User, dir: "sig") # => "sig/user.rbs"Custom Methods and Steep
Structure::RBS.emit generates type signatures for custom methods with parameters and return types defaulting to untyped:
User = Structure.new do
attribute(:age, Integer)
# steep:ignore:start
def adult?
age >= 18
end
# steep:ignore:end
end
Structure::RBS.emit(User)
# => ...
# def adult?: () -> untyped
# endThe generated signatures work for code that uses your Structure classes, but Steep may report warnings in definition files when custom methods are present. This happens because the Structure.new block is evaluated in two different contexts at runtime (once for DSL methods like attribute, once for custom methods), but Steep can only analyze one static context. Wrap custom methods with # steep:ignore:start and # steep:ignore:end comments, or exclude definition files from Steep checking in your Steepfile.
See also: RBS Data/Struct documentation, RBS issue #654, RBS issue #1077
Development
$ bundle install
$ bundle exec rbs collection install
$ bundle exec rakePerformance Considerations
String-based method generation with class_eval is more performant but also overcomplicates the code. For now, I prioritize legibility.
