Structure
Structure your data
Turn unruly hashes into clean, immutable 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? # => true
Built on the Ruby Data class 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 # => true
Key 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? # => true
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 # => false
Array 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"
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 # => false
Supported 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
.parse
method - 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>
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.
Development
$ bundle install
$ rake test