Project

structure

0.01
No release in over 3 years
Provides a DSL for generating immutable Ruby Data objects with type coercion and data transformation capabilities.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies
 Project Readme

Structure

CI/CD Pipeline

Ruby

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?  # => true

Built 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 # => 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

Optional 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: :age

Use 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: :name

Non-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: :name

This 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   # => 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"

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       # => 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>

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.99

Self-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?     # => false

Custom 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
#    end

The 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 rake

Performance Considerations

String-based method generation with class_eval is more performant but also overcomplicates the code. For now, I prioritize legibility.