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.
Add to your Gemfile:
gem "structure"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)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 # => trueClean 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? # => trueStructure wraps Data classes. All attributes are required when creating instances, even if their value is nil. Use attribute? to make a key optional and null: false to reject nil values.
| DSL | Key | Value | RBS |
|---|---|---|---|
attribute(:foo, String, null: false) | always present | never nil | String |
attribute(:foo, String) | always present | can be nil | String? |
attribute?(:foo, String, null: false) | may be absent | never nil when present | String? |
attribute?(:foo, String) | may be absent | can be nil | String? |
The attribute? + null: false combination maps naturally to GraphQL's non-null type modifier (String!): the field must be non-null when present but may be absent from the response entirely. Since absent optional attributes default to nil, null: false is a parse-time guard rather than a type-level guarantee. Hence, String? in the RBS column.
User = Structure.new do attribute(:name, String) attribute?(:age, Integer) attribute?(:handle, String, null: false) end User.parse(name: "Alice") # works, age defaults to nil User.parse(name: "Alice", handle: "a") # works User.parse(age: 10) # ArgumentError: missing keyword: :name User.parse(name: "Alice", handle: nil) # ArgumentError: cannot be null: :handleHandle 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 # => falseArrays 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]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"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"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"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">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 # => falseStructure 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 stringsTime- Parses various time formatsURI- 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>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.99Build 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.
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.
Structure classes respond to load and dump for use with Marshal, YAML, or Rails serialize:
Settings = Structure.new do attribute(:theme, String, default: "light") attribute(:notifications, :boolean, default: true) end settings = Settings.parse(theme: "dark") # Dump to hash, load back to instance hash = Settings.dump(settings) # => {theme: "dark", notifications: true} Settings.load(hash) # => #<data Settings theme="dark", notifications=true> # Use as Rails serialize coder class User < ApplicationRecord serialize :settings, coder: Settings endDefine 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"]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"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
For Sorbet users, Structure includes a Tapioca DSL compiler that automatically generates RBI files:
# In your project using Structure bundle exec tapioca dslThis will generate RBI files for all Structure classes in your project, giving you full type checking with Sorbet.
Generate RBS files for multiple classes at once:
require 'structure/rbs' # From an array of classes Structure::RBS.write_all([User, Order, Product], dir: "sig") # From a module namespace Structure::RBS.write_all(MyApp::Models, dir: "sig")$ bundle install $ bundle exec rbs collection install $ bundle exec rakeString-based method generation with class_eval is more performant but also overcomplicates the code. For now, I prioritize legibility.
