Struct & OpenStruct
Reviewed & published by Brayan K
A Struct is a built-in Ruby shortcut that generates a small class bundling a fixed set of named attributes — complete with constructor, accessors, and value equality — in a single line of code.
Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
You'll also meet the flexible runtime-attribute OpenStruct and the immutable Data.define (Ruby 3.2+), and learn when each beats a full class.
What You'll Learn in This Lesson
- Defining a Struct with attributes and methods
- Building with keyword_init for named arguments
- Free value equality, to_a, to_h, and members
- When a Struct beats a full class
- OpenStruct for runtime attributes (require 'ostruct')
- Data.define for immutable value objects
1️⃣ Defining a Struct
Struct.new(:a, :b) returns a new class with readers and writers for each attribute and a constructor. Pass a block to add your own methods — inside them, you can refer to the attributes by name directly.
# Struct.new builds a small class with attributes in one line
Point = Struct.new(:x, :y)
p1 = Point.new(3, 4)
puts p1.x # 3
puts p1.y # 4
p1.x = 10 # attributes are read/write
puts p1.x # 10
# You can add methods in a block:
Point2 = Struct.new(:x, :y) do
def distance_to_origin
Math.sqrt(x**2 + y**2)
end
end
puts Point2.new(3, 4).distance_to_origin # 5.02️⃣ keyword_init, Equality & Conversions
Add keyword_init: true to construct by name instead of position. Structs come with value-based == for free, plus handy conversions like to_a, to_h, and the class method members.
# keyword_init lets you build with named arguments (clearer at the call site)
User = Struct.new(:name, :role, keyword_init: true)
u = User.new(name: "Ada", role: "admin")
puts u.name
puts u.role
# Structs get == for free: equal when all members match
P = Struct.new(:n)
puts (P.new(1) == P.new(1)) # true (same struct class + members)
puts (P.new(1) == P.new(2)) # false
# Structs are also enumerable-ish: to_a, to_h, members
Coord = Struct.new(:lat, :lng)
c = Coord.new(51.5, -0.1)
p c.to_a
p c.to_h
p Coord.members3️⃣ OpenStruct & Data.define
After require 'ostruct', an OpenStruct lets you add attributes at runtime and returns nil for unknown ones. For an immutable value object, Data.define (Ruby 3.2+) gives readers and value equality but freezes instances — no writers.
# OpenStruct: an object whose attributes you can set on the fly
require 'ostruct'
config = OpenStruct.new(host: "localhost", port: 3000)
puts config.host
config.timeout = 30 # add a brand-new attribute at runtime
puts config.timeout
puts config.missing.inspect # unknown attribute -> nil (no error)
# Data.define (Ruby 3.2+): an IMMUTABLE value object
Measure = Data.define(:amount, :unit)
m = Measure.new(amount: 5, unit: "kg")
puts m.amount
puts m.unit
begin
m.amount = 10 # frozen! cannot reassign
rescue NoMethodError => e
puts "Immutable: #{e.message.split(' for ').first}"
endYour turn. Fill in each ___ blank, then run it.
# 🎯 YOUR TURN — fill in the blanks, then run with: ruby book.rb
Book = Struct.new(:title, :author, :pages, ___: true) do # 👉 build by NAME
def summary
"#{title} by #{author} (#{pages} pages)"
end
end
b = Book.___(title: "Dune", author: "Herbert", pages: 412) # 👉 make an instance
puts b.summary
puts b.pages
# ✅ Expected output:
# Dune by Herbert (412 pages)
# 412Mini-Challenge: A Shopping Cart
Model cart items as a Struct with a subtotal method, then sum them with sum(&:subtotal). Run with ruby cart.rb.
# 🎯 MINI-CHALLENGE: a shopping cart with a Struct.
# Save as cart.rb and run with: ruby cart.rb
#
# 1. Define Product = Struct.new(:name, :price, :qty, keyword_init: true)
# with a method subtotal that returns price * qty.
# 2. Build a cart array of 3 products.
# 3. Print "<name>: $<subtotal>" for each, then the grand total
# using cart.sum(&:subtotal).
#
# ✅ Example output:
# Pen: $6
# Book: $12
# Mug: $16
# Total: $34
# your code hereCommon Errors (and the fix)
- ❌ point = Struct.new(:x) (lowercase) → ✅ assign to a constant: Point = Struct.new(:x).
- ❌ Passing keywords without keyword_init: true → ✅ add it, or pass positional arguments.
- ❌ Forgetting require 'ostruct' → ✅ OpenStruct isn't loaded by default.
- ❌ A typo'd OpenStruct getter returning nil → ✅ prefer a Struct so unknown methods raise loudly.
- ❌ Trying to reassign a Data.define field → ✅ it's frozen; use obj.with(field: value) for a copy.
Pro Tips
- 💡 Free comparisons: include Comparable and define <=> in the block to sort structs.
- 💡 Immutable by default: prefer Data.define when values shouldn't change after creation.
- 💡 Destructure in blocks: a Struct unpacks like an array, so arr.map { |(a, b)| ... } works.
📋 Quick Reference — Struct & Friends
| Goal | Ruby Syntax |
|---|---|
| Define a Struct | Struct.new(:x, :y) |
| Named arguments | keyword_init: true |
| Add methods | Struct.new(:x) do ... end |
| Convert | to_a , to_h , members |
| Runtime attributes | OpenStruct.new(...) |
| Immutable value | Data.define(:a, :b) |
🎉 Lesson Complete!
- ✅ Struct.new generates a class with attributes in one line
- ✅ Add methods in a block; use keyword_init: true for named args
- ✅ Free value ==, plus to_a/to_h/members
- ✅ OpenStruct allows runtime attributes (but typos return nil)
- ✅ Data.define creates immutable value objects (Ruby 3.2+)
Practice quiz
What does Struct.new(:x, :y) return?
- An instance with x and y
- A hash
- A new class with readers, writers, and a constructor
- An array
Answer: A new class with readers, writers, and a constructor. Struct.new returns a new class that bundles the named attributes with accessors and a constructor.
By convention, you assign the result of Struct.new to a...
- constant (capitalized name)
- lowercase local variable
- global variable
- symbol
Answer: constant (capitalized name). Assign it to a constant like Point so it becomes a proper, named class.
What does keyword_init: true change about a Struct?
- Makes it immutable
- Removes the writers
- Adds inheritance
- Lets you construct with named arguments
Answer: Lets you construct with named arguments. With keyword_init: true you pass named arguments like User.new(name: "Ada", role: "admin").
Two Struct instances with the same members compare with == as...
- always false
- equal (true) when same struct class and members match
- an error
- equal only if same object_id
Answer: equal (true) when same struct class and members match. Structs get value-based == for free: equal when the same struct class and all members match.
Which method lists a Struct's attribute names?
- members
- keys
- fields
- attributes
Answer: members. The class method members returns the attribute names, e.g. [:lat, :lng].
Are a plain Struct's attributes read-only?
- Yes, always frozen
- Only with keyword_init
- No, they have writers and can be reassigned
- Only the first one
Answer: No, they have writers and can be reassigned. A plain Struct gives read/write accessors, so p1.x = 10 works.
What must you do before using OpenStruct?
- Nothing, it's built in
- require 'ostruct'
- require 'set'
- include OpenStruct
Answer: require 'ostruct'. OpenStruct isn't loaded by default — add require 'ostruct' first.
What does an OpenStruct return for an attribute you never set?
- An error
- false
- an empty string
- nil
Answer: nil. OpenStruct returns nil for unknown attributes, which is why typos can hide silently.
What does Data.define (Ruby 3.2+) create?
- A mutable struct
- An immutable (frozen) value object
- A hash subclass
- A module
Answer: An immutable (frozen) value object. Data.define creates an immutable value object — instances are frozen with no attribute writers.
How do you 'change' a field on a Data.define instance?
- Reassign it directly
- Use a setter
- Call obj.with(field: newvalue) for a fresh copy
- It can't change at all
Answer: Call obj.with(field: newvalue) for a fresh copy. Data instances are frozen, so .with(field: value) returns a new copy with the change.
Continue this course
- Previous: Advanced Enumerable (reduce, group_by, chunk)
- Next: Sets — Unique collections with union, intersection, difference, and subset?
- Quick reference: Ruby cheat sheet
Frequently asked questions
When should I use a Struct instead of a full class?
Reach for a Struct when you mainly need a bundle of named attributes with a little behaviour attached. In one line, Struct.new(:x, :y) gives you a constructor, reader and writer methods, value-based ==, and helpers like to_a, to_h, and members — boilerplate you'd otherwise write by hand. Graduate to a full class when the object grows real complexity: many private helpers, careful validation in the initializer, inheritance, or when you don't want the free attribute writers and equality that a Struct hands out.
What does keyword_init: true change?
Without it, you construct a Struct positionally: User.new("Ada", "admin") — concise but easy to mix up when there are several fields. With keyword_init: true you must pass named arguments: User.new(name: "Ada", role: "admin"), which is self-documenting and order-independent. As of Ruby 3.2 a Struct can even accept both styles. For anything beyond two or three fields, keyword initialization makes call sites far more readable.
How is OpenStruct different from a Struct, and why is it discouraged?
A Struct fixes its attributes up front, so its members are known and it's fast. An OpenStruct (after require 'ostruct') lets you set arbitrary attributes at runtime and returns nil for any you never defined — flexible, but slower, and it silently hides typos because a misspelled getter just returns nil instead of raising. OpenStruct is handy for quick prototypes, mock objects, or wrapping loose data like parsed JSON, but for production value objects a Struct or Data.define is safer and faster.
What is Data.define and how does it compare to Struct?
Data.define, introduced in Ruby 3.2, creates an immutable value object: like a Struct it gives you named readers, a keyword (and positional) constructor, value equality, and to_h, but it has NO attribute writers, so instances are frozen after creation. To 'change' one you call .with(field: newvalue), which returns a fresh copy. Prefer Data.define when you want the safety and clarity of immutable values, and a mutable Struct only when you genuinely need to reassign fields in place.