Struct, Data and Value Objects

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby has two core tools for small records: Struct (since Ruby 1.x), a mutable record that also behaves like a collection, and Data (Ruby 3.2), an immutable record that is meant to be a value. Both generate a class from a list of member names, with accessors, equality, a hash, an inspect and support for pattern matching. This page covers how to define and construct each of them, what the generated class gives you, how to validate and derive state in initialize, how value objects behave (equality, hashing, freezing, ordering), how to pick between a Hash, a Struct, a Data and a plain class, why OpenStruct is not on the list, and what each choice costs in allocations.

It builds on Classes and Objects (a Struct or Data class is an ordinary class built for you), Methods and Parameters (positional and keyword parameters decide how the constructors behave) and Object Lifecycle, Freezing and Copying (what freeze does and does not protect). The equality methods themselves are treated in Equality, Hashing and Comparison; this page shows what the generated classes do with them. The case/in forms used in the pattern matching section are in Pattern Matching. For how other languages model the same idea, see Java (records), Kotlin (data classes) and Python (dataclasses and named tuples).

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown is the real output. The examples that need the gem are run with ruby -Ilib, so require "shelf" finds it (see the shelf gem source). In the shelf gem, Shelf::Author, Shelf::Isbn and Shelf::Book are Data classes and Shelf::Config is a Struct, so the page’s examples use the same pieces the gem does.

Choosing between Hash, Struct, Data and a class

A record is a small group of named values that travel together. Ruby gives four ways to write one, and the right one depends on whether the names are known in advance, whether the record changes, and whether it has behaviour of its own.

flowchart TD Start["A group of named values"] --> Known{"Are the names known when you write the code?"} Known -->|No: keys come from input| Hash["Hash"] Known -->|Yes| Behaviour{"More than a few small methods, collaborators or hidden state?"} Behaviour -->|Yes| Plain["Plain class"] Behaviour -->|No| Changes{"Does it change after it is created?"} Changes -->|Yes: settings, counters, accumulators| Struct["Struct"] Changes -->|No: it is a value| Data["Data"] Hash -.->|untrusted keys, never| OpenStruct["OpenStruct"]

The diagram is a starting point, not a law: a Struct that grows methods and invariants becomes a class, and a Hash whose keys turn out to be fixed becomes a Struct or a Data. The table compares the four on what matters in practice.

Hash Struct Data Plain class

Members fixed

No

Yes

Yes

Yes (instance variables, by convention)

Reading

h[:title], h.fetch(:title)

s.title, s[:title], s[0]

d.title

o.title (if you write the reader)

Typo on read

nil (or KeyError with fetch)

NoMethodError

NoMethodError

NoMethodError

Writing

h[:year] = 1966

s.year = 1966

none; d.with(year: 1966) makes a copy

only if you write the writer

Missing member at construction

not applicable

nil

ArgumentError

whatever initialize does

Frozen

only if you freeze it

only if you freeze it

always, at the end of initialize

only if you freeze it

Value equality

==, eql?, hash by content

by class and members

by class and members

identity, unless you write it

Collection API (each, to_a, map)

Yes

Yes (Enumerable)

No

No

Pattern matching

deconstruct_keys built in

both deconstruct and deconstruct_keys

both

only if you write them

Behaviour

none

in the block or a subclass

in the block or a subclass

the whole point

The same record in all four shapes shows how a mistake surfaces. Nothing below is an error in a Hash, which is the reason Struct and Data exist.

# frozen_string_literal: true

def show
  r = yield
  p r
rescue StandardError => e
  puts "#{e.class}: #{e.message}"
end

# The same record in four shapes, and what a mistake does in each.
hash = { title: "Dune", year: 1965 }
Entry = Struct.new(:title, :year)
Frozen = Data.define(:title, :year)
class Plain
  attr_reader :title, :year

  def initialize(title:, year:)
    @title = title
    @year = year
  end
end

shapes = {
  hash:, struct: Entry.new("Dune", 1965), data: Frozen.new("Dune", 1965), plain: Plain.new(title: "Dune", year: 1965)
}

puts "typo on read"
show { hash[:titel] }
show { hash.fetch(:titel) }
show { shapes[:struct].titel }
show { shapes[:data].titel }
show { shapes[:plain].titel }

puts "equality"
show { shapes[:struct] == Entry.new("Dune", 1965) }
show { shapes[:data] == Frozen.new("Dune", 1965) }
show { shapes[:plain] == Plain.new(title: "Dune", year: 1965) }

puts "write"
show { hash[:year] = 1966 }
show { shapes[:struct].year = 1966 }
show { shapes[:data].year = 1966 }
show { shapes[:plain].year = 1966 }

puts "missing member at construction"
show { Entry.new("Dune") }
show { Frozen.new("Dune") }
show { Plain.new(title: "Dune") }
typo on read
nil
KeyError: key not found: :titel
NoMethodError: undefined method 'titel' for an instance of Entry
NoMethodError: undefined method 'titel' for an instance of Frozen
NoMethodError: undefined method 'titel' for an instance of Plain
equality
true
true
false
write
1966
1966
NoMethodError: undefined method 'year=' for an instance of Frozen
NoMethodError: undefined method 'year=' for an instance of Plain
missing member at construction
#<struct Entry title="Dune", year=nil>
ArgumentError: missing keyword: :year
ArgumentError: missing keyword: :year

Source: Struct, Data

Read the output in three groups.

  • A typo on read. The Hash returns nil and carries on with a wrong value until something fails far away; Hash#fetch fails at once with KeyError. The three class-based shapes fail at once with NoMethodError, naming the method and the class.

  • Equality. Struct and Data compare by class and members; the plain class compares by identity until you write ==, eql? and hash yourself.

  • A missing member at construction. A Struct fills the member with nil; a Data refuses to exist without all its members, which is the right behaviour for a value. A plain class decides in its own initialize.

Struct

Struct.new returns a new class, a subclass of Struct, with one accessor pair per member. Assign it to a constant and the constant becomes the class name. The generated class includes Enumerable, so a struct is a small fixed-length collection as well as a record.

Defining and constructing a Struct

# frozen_string_literal: true

Bookmark = Struct.new(:title, :page)

mark = Bookmark.new("Dune", 120)
p mark
p Bookmark.new("Emma")
p Bookmark.new(title: "Emma", page: 7)
p Bookmark.members
p mark.to_a
p mark.to_h

p mark.title
p mark[:title]
p mark["page"]
p mark[1]
p mark[-1]

mark.page += 10
mark[:title] = "Dune Messiah"
p mark

begin
  Bookmark.new("Dune", 1, 2)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

begin
  Bookmark.new(chapter: 3)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

begin
  mark[:chapter]
rescue NameError => e
  puts "#{e.class}: #{e.message}"
end

begin
  mark[5]
rescue IndexError => e
  puts "#{e.class}: #{e.message}"
end
#<struct Bookmark title="Dune", page=120>
#<struct Bookmark title="Emma", page=nil>
#<struct Bookmark title="Emma", page=7>
[:title, :page]
["Dune", 120]
{title: "Dune", page: 120}
"Dune"
"Dune"
120
120
120
#<struct Bookmark title="Dune Messiah", page=130>
ArgumentError: struct size differs
ArgumentError: unknown keywords: chapter
NameError: no member 'chapter' in struct
IndexError: offset 5 too large for struct(size:2)

Source: Struct.new

  • Members are symbols (strings after the first argument are converted too). Bookmark.members and mark.members return them in declaration order.

  • Construction takes positional arguments or, since Ruby 3.2, keyword arguments without any option. Members that are not given are nil; more arguments than members raise ArgumentError ("struct size differs"); an unknown keyword raises ArgumentError ("unknown keywords: chapter").

  • Access is by accessor (mark.title), by name (mark[:title], mark["title"]) or by position (mark[1], with negative positions counting from the end). An unknown name is a NameError and a position out of range is an IndexError. []= and the accessor writers change the struct in place.

  • Conversion: to_a (aliases deconstruct and values) returns the values; to_h returns a Hash keyed by member name and accepts a block that returns key/value pairs.

  • Inspection: inspect and to_s print #<struct Bookmark title="Dune", page=120>, the class name followed by name=value pairs. An anonymous struct class prints #<struct title="Dune">.

Keyword construction and keyword_init

Before Ruby 3.2 a struct could be built from keywords only with keyword_init: true. Since 3.2 the default (keyword_init: nil) accepts both, and keyword_init: is only needed to be strict.

# frozen_string_literal: true

Plain = Struct.new(:title, :page)
Strict = Struct.new(:title, :page, keyword_init: true)
Legacy = Struct.new(:title, :page, keyword_init: false)

p Plain.keyword_init?
p Strict.keyword_init?
p Legacy.keyword_init?
p Strict
p Plain.new(title: "Dune", page: 1)
p Plain.new({ title: "Dune", page: 1 })
p Plain.new("Dune", page: 1)
p Strict.new(title: "Dune")
p Legacy.new(title: "Dune", page: 1)

begin
  Strict.new("Dune", 1)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

begin
  Strict.new(title: "Dune", chapter: 1)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end
nil
true
false
Strict(keyword_init: true)
#<struct Plain title="Dune", page=1>
#<struct Plain title={title: "Dune", page: 1}, page=nil>
#<struct Plain title="Dune", page={page: 1}>
#<struct Strict title="Dune", page=nil>
#<struct Legacy title={title: "Dune", page: 1}, page=nil>
ArgumentError: wrong number of arguments (given 2, expected 0)
ArgumentError: unknown keywords: chapter
keyword_init: Behaviour

nil (the default)

Keywords only (Plain.new(title: "Dune", page: 1)) are treated as member values. Positional values are positional. A Hash passed as a positional value stays a positional value.

true

Keywords only. Positional arguments raise ArgumentError ("wrong number of arguments (given 2, expected 0)"). The class prints as Strict(keyword_init: true).

false

Never keywords: Legacy.new(title: "Dune", page: 1) stores the Hash {title: "Dune", page: 1} in the first member.

Struct.keyword_init? returns true, false or nil for the three cases. Mixing a positional argument with keywords is not an error for nil: the keywords become a trailing positional Hash (Plain.new("Dune", page: 1) above stores {page: 1} in page). The strictness of keyword_init: true is its only remaining use. RuboCop’s Style/RedundantStructKeywordInit (off by default, since 1.85) flags the option as redundant for Ruby 3.2 and later, with an unsafe autocorrect for exactly that reason: removing true also removes the rejection of positional arguments.

Adding behaviour and overriding initialize

Struct.new takes a block that is evaluated in the new class, so methods can sit next to the members. A struct is Enumerable over its member values, which makes each, map, grep, sum and friends work, and gives it each_pair, values_at, select and filter of its own.

# frozen_string_literal: true

Reading = Struct.new(:title, :page, :pages) do
  def initialize(title, page = 0, pages = nil)
    raise ArgumentError, "page must not be negative" if page.negative?

    super
  end

  def progress = pages ? (page * 100.0 / pages).round(1) : nil

  def to_s = "#{title}: page #{page}"
end

reading = Reading.new("Dune", 120, 412)
puts reading
p reading.progress
p Reading.new("Emma").to_a

p reading.each.to_a
p reading.each_pair.to_a
p reading.values_at(0, 2)
p reading.grep(Integer)
doubled = reading.filter_map { it * 2 if it.is_a?(Integer) }
p doubled
total = reading.sum { it.is_a?(Integer) ? it : 0 }
p total
p reading.size
p reading.member?(:page)
p Reading.new(title: "Emma")

begin
  Reading.new("Dune", -1)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

reading.freeze
begin
  reading.page = 130
rescue FrozenError => e
  puts "#{e.class}: #{e.message}"
end
Dune: page 120
29.1
["Emma", 0, nil]
["Dune", 120, 412]
[[:title, "Dune"], [:page, 120], [:pages, 412]]
["Dune", 412]
[120, 412]
[240, 824]
532
3
false
#<struct Reading title={title: "Emma"}, page=0, pages=nil>
ArgumentError: page must not be negative
FrozenError: can't modify frozen Reading: #<struct Reading title="Dune", page=120, pages=412>
  • Override initialize to validate or default, and call super to store the values. A bare super passes the method’s own arguments on, including the defaults.

  • An initialize with positional parameters replaces the keyword form. Reading.new(title: "Emma") stored the Hash in title, because the new initialize does not accept keywords. Either keep the constructor positional, or accept (*args, **kwargs) and forward both.

  • Enumerable over the values is rarely what a record needs. reading.sum or reading.max work only when every member is comparable or numeric; the example restricts sum to the integers. Use to_h when you need names.

  • freeze makes the struct read-only: the setters then raise FrozenError (the message includes the inspected object). The freeze is shallow, as everywhere in Ruby.

Beyond what the example uses, a struct offers dig (nested lookup through members, arrays and hashes), values_at, size and length, each without a block (an enumerator), eql?, hash and ==. Struct#members and Struct.members list the names.

Method What it does

Struct.new(*members, keyword_init: nil) { ... }

Defines the class. A String as the first argument defines the constant under Struct instead (see the pitfalls).

Klass.members, #members

Member names as symbols.

#[], #[]=

Read or write by Symbol, String or Integer position.

#to_a, #deconstruct, #values

The values, in member order.

#to_h, #to_h { |name, value| ... }

A Hash by member name; the block maps each pair.

#each, #each_pair

Iterate values, or name/value pairs (enumerators without a block).

#values_at(*positions), #dig(name, *rest)

Selected values; nested lookup.

#select, #filter

The values for which the block is true.

#size, #length

The number of members.

#==, #eql?, #hash

Value comparison: same class and equal members (==) or eql? members (eql? and hash).

#deconstruct_keys(keys)

A Hash for hash patterns.

#inspect, #to_s

#<struct Name a=1, b=2>.

Struct and Data share the same internals, but only Struct has the collection half of this table. The pages for Enumerable explain the mixed-in methods.

A Struct in the shelf gem

A Struct is the right tool when the record is a small bag of settings that several parts of a program read and one part changes. Shelf::Config is exactly that: a mutable record with a shared instance.

# frozen_string_literal: true

require "shelf"

# Shelf::Config is a Struct: a small mutable bag of settings.
p Shelf::Config.members
p Shelf.config.loan_days

Shelf.configure { it.loan_days = 21 }
p Shelf.config.loan_days
p Shelf.config.to_h.keys
p Shelf.config.store.class
[:store, :loan_days]
14
21
[:store, :loan_days]
Shelf::Store::Memory

Source: Struct

The block form Shelf.configure { it.loan_days = 21 } uses the implicit it parameter (Ruby 3.4) and the struct’s writer. A Data would not work here: each change would need a new object, and every holder of the old one would keep the old value. When the shared mutable state is the point, a mutable record is correct; when sharing is incidental, prefer the immutable one.

Data

Data.define (Ruby 3.2) generates an immutable value class. The design goals are stated by the shape of the API: no writers, no [], no each, no to_a (only the pattern-matching deconstruct), every member required, and instances frozen as soon as they are built.

Defining and constructing a Data class

# frozen_string_literal: true

Coordinate = Data.define(:lat, :lng)

here = Coordinate.new(41.65, -0.88)
p here
p Coordinate.new(lat: 41.65, lng: -0.88)
p Coordinate[41.65, -0.88]
p Coordinate[lat: 41.65, lng: -0.88]
p Coordinate.members
p here.lat
p here.to_h
p here.frozen?

moved = here.with(lat: 42.0)
p moved
p here
p here.with.equal?(here)

def show
  yield
rescue StandardError => e
  puts "#{e.class}: #{e.message}"
end

show { Coordinate.new(41.65) }
show { Coordinate.new }
show { Coordinate.new(1, 2, 3) }
show { Coordinate.new(lat: 1, lng: 2, alt: 3) }
show { Coordinate.new(1, lng: 2) }
show { here.lat = 0 }
show { here.instance_variable_set(:@lat, 0) }
show { here.with(41) }
show { here.with(alt: 3) }
show { Data.new }
show { Data.define(:lat, :lat) }
show { Data.define(:lat=) }
#<data Coordinate lat=41.65, lng=-0.88>
#<data Coordinate lat=41.65, lng=-0.88>
#<data Coordinate lat=41.65, lng=-0.88>
#<data Coordinate lat=41.65, lng=-0.88>
[:lat, :lng]
41.65
{lat: 41.65, lng: -0.88}
true
#<data Coordinate lat=42.0, lng=-0.88>
#<data Coordinate lat=41.65, lng=-0.88>
true
ArgumentError: missing keyword: :lng
ArgumentError: missing keywords: :lat, :lng
ArgumentError: wrong number of arguments (given 3, expected 0..2)
ArgumentError: unknown keyword: :alt
ArgumentError: wrong number of arguments (given 2, expected 0)
NoMethodError: undefined method 'lat=' for an instance of Coordinate
FrozenError: can't modify frozen Coordinate: #<data Coordinate lat=41.65, lng=-0.88>
ArgumentError: wrong number of arguments (given 1, expected 0)
ArgumentError: unknown keyword: :alt
NoMethodError: undefined method 'new' for class Data
ArgumentError: duplicate member: lat
ArgumentError: invalid data member: lat=

Source: Data.define

  • Construction: new and [] accept positional arguments or keyword arguments, but not both. The positional form is converted to keywords before initialize runs, so initialize always receives keywords.

  • Every member is required. A missing one raises ArgumentError ("missing keyword: :lng", or "missing keywords: :lat, :lng"); an unknown keyword raises ArgumentError ("unknown keyword: :alt"); too many positional values raise ArgumentError ("wrong number of arguments (given 3, expected 0..2)"). A positional value mixed with keywords is also an ArgumentError.

  • No writers. here.lat = 0 is a NoMethodError because the setter does not exist; instance_variable_set is a FrozenError because the object is frozen. frozen? is true for every instance.

  • with returns a copy with some members replaced: here.with(lat: 42.0). Called without arguments it returns self. It accepts only keywords (a positional argument is an ArgumentError) and only existing members (an unknown keyword is an ArgumentError). The copy is built through new, so initialize and its validation run again.

  • Member names are symbols or strings and must be unique and not end in = ("duplicate member: lat", "invalid data member: lat="). Data.define with no members is allowed and useful for marker values such as NotFound = Data.define.

  • Data itself cannot be instantiated: Data.new is a NoMethodError; the class method on Data is define.

  • Conversion and inspection: to_h (and to_h { ... }) returns a Hash; members lists the names; inspect and to_s print #<data Coordinate lat=41.65, lng=-0.88>. Unlike a struct, a Data has no to_a, each, [] or Enumerable, but it does define deconstruct (array patterns) and deconstruct_keys (hash patterns).

The class returned by define takes a block, evaluated in the new class like the Class.new block, so methods and include calls go inside it (see Block form or subclass).

Validation, defaults and conversion in initialize

Because the constructors funnel into initialize with keywords, initialize is where a value object validates and normalises. Override it with keyword parameters and call super with keywords. Shelf::Isbn is the pattern: strip the hyphens, reject anything that is not a valid ISBN-13, then store the cleaned digits.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
p isbn
p isbn.with(digits: "9780306406157") == isbn

def show
  yield
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

show { Shelf::Isbn.new("978-0-306-40615-8") }
show { Shelf::Isbn.new(digits: "123") }
show { isbn.with(digits: "123") }

# Defaults and conversions: override initialize with keyword parameters,
# then call super with keywords. Positional arguments reach it as keywords.
Measure = Data.define(:amount, :unit) do
  def initialize(amount:, unit: "pages")
    super(amount: Float(amount), unit:)
  end
end

p Measure.new("10")
p Measure.new(3, "km")
p Measure.new(amount: 2)
show { Measure.new }
show { Measure.new("many") }
#<data Shelf::Isbn digits="9780306406157">
true
ArgumentError: invalid ISBN: 978-0-306-40615-8
ArgumentError: invalid ISBN: 123
ArgumentError: invalid ISBN: 123
#<data Measure amount=10.0, unit="pages">
#<data Measure amount=3.0, unit="km">
#<data Measure amount=2.0, unit="pages">
ArgumentError: missing keyword: :amount
ArgumentError: invalid value for Float(): "many"

Source: Data.new

  • Validation runs on every construction path: new, [] and with. isbn.with(digits: "123") fails exactly as Shelf::Isbn.new(digits: "123") does, so a value that exists is a valid value.

  • The positional call Measure.new(3, "km") works with the keyword-only initialize, because new has already converted the arguments to keywords.

  • A default is a keyword default (unit: "pages"). A missing member without a default is reported by Ruby’s own keyword check ("missing keyword: :amount").

  • The conversion (Float(amount)) happens before super; the stored member is the converted value, and an invalid one raises the usual ArgumentError from Kernel#Float. See Conversions and Coercion for the Integer() and Float() family.

  • Call super(...) with keywords, not positionally: the inherited initialize takes keywords only.

  • Raise ArgumentError for bad input, as the keyword check does, and keep I/O (files, network, clocks) out of initialize: a value should be cheap and deterministic to build.

Shallow immutability

Data freezes the object, not the objects it points to. A Data member that is an Array, a Hash or a String stays mutable unless you freeze it yourself, and the documentation says so explicitly.

# frozen_string_literal: true

Tagged = Data.define(:title, :tags)

shelved = Tagged.new("Dune", %w[sci-fi classic])
shelved.tags << "desert"
p shelved
p shelved.frozen?
p shelved.tags.frozen?

# Copy and freeze what the object keeps.
Safe = Data.define(:title, :tags) do
  def initialize(title:, tags:)
    super(title: title.dup.freeze, tags: tags.map { it.dup.freeze }.freeze)
  end
end

source = %w[sci-fi classic]
safe = Safe.new("Dune", source)
source << "desert"
p safe
p safe.tags.frozen?

begin
  safe.tags << "desert"
rescue FrozenError => e
  puts "#{e.class}: #{e.message}"
end

p Ractor.shareable?(shelved)
p Ractor.shareable?(safe)
p Ractor.shareable?(Ractor.make_shareable(shelved))
#<data Tagged title="Dune", tags=["sci-fi", "classic", "desert"]>
true
false
#<data Safe title="Dune", tags=["sci-fi", "classic"]>
true
FrozenError: can't modify frozen Array: ["sci-fi", "classic"]
false
true
true
  • shelved.frozen? is true, yet shelved.tags << "desert" changed the value that shelved represents. Anything that stored shelved as a Hash key now holds a key whose hash changed.

  • The fix is in initialize: copy what you are given (tags.map { it.dup.freeze }) so later changes to the caller’s Array do not leak in, and freeze what you keep. safe is immune to source << "desert" and rejects safe.tags << "x" with a FrozenError.

  • Ractor.shareable? is a quick deep-immutability test: it is false for shelved (a mutable Array inside) and true for safe. Ractor.make_shareable freezes an object graph in place. See Object Lifecycle, Freezing and Copying and Ractors.

  • Members that are themselves Data values (Shelf::Book holding a Shelf::Isbn and a Shelf::Author) are fine: they are frozen already. The risk is members that are collections or strings built elsewhere.

Derived state and memoization

An instance is frozen at the end of Data#initialize, so the usual @x ||= compute memoization fails.

# frozen_string_literal: true

Lazy = Data.define(:pages) do
  def reading_hours = @reading_hours ||= pages / 40.0
end

begin
  Lazy.new(412).reading_hours
rescue FrozenError => e
  puts "#{e.class}: #{e.message}"
end

# Compute before super: instance variables set before Data#initialize runs
# are kept, because the object is frozen only at the end of initialize.
Eager = Data.define(:pages) do
  attr_reader :reading_hours

  def initialize(pages:)
    @reading_hours = pages / 40.0
    super
  end
end

eager = Eager.new(412)
p eager.reading_hours
p eager
p eager.instance_variables
p eager == Eager.new(412)
p eager.frozen?
FrozenError: can't modify frozen Lazy: #<data Lazy pages=412>
10.3
#<data Eager pages=412>
[:@reading_hours]
true
true

Source: Data.new

The lazy form raises FrozenError because the object is already frozen when reading_hours runs. The eager form computes the value in initialize before calling super: instance variables set before Data#initialize are kept, and the freeze comes after. Three consequences:

  • The derived value is not a member: inspect omits it, and ==, eql?, hash and to_h ignore it. It must be a pure function of the members, or equal objects would differ.

  • Deriving in a method (def reading_hours = pages / 40.0) is almost always better: it costs one division. Memoize only when the computation is expensive, and keep the cache outside the object if you must keep the object frozen.

  • Do not rely on a hidden instance variable for anything the equality contract should see; add a member instead.

Block form or subclass

There are two ways to add methods to a generated class, and they produce different class trees.

# frozen_string_literal: true

# Style 1: assign the class, add methods in the block.
Isbn10 = Data.define(:digits) do
  def to_s = digits
end

# Style 2: inherit from the generated class.
class Isbn13 < Data.define(:digits) # rubocop:disable Style/DataInheritance
  def to_s = digits
end

p Isbn10.superclass
p Isbn13.superclass.inspect.start_with?("#<Class:0x")
p Isbn10.ancestors.take(3)
p Isbn13.ancestors.take(4).map(&:name)
p Isbn13.superclass.name
p Isbn13.superclass.superclass
p Isbn13.members
p Isbn13.new("9780306406157")

# A class that is never assigned to a constant is anonymous.
anonymous = Data.define(:digits)
p anonymous.name
p anonymous.new("9780306406157")
anonymous.set_temporary_name("fake<Isbn>")
p anonymous.new("9780306406157")
Data
true
[Isbn10, Data, Object]
["Isbn13", nil, "Data", "Object"]
nil
Data
[:digits]
#<data Isbn13 digits="9780306406157">
nil
#<data digits="9780306406157">
#<data fake<Isbn> digits="9780306406157">

Source: Data.define

  • Isbn10 = Data.define(:digits) do ... end creates one class, named Isbn10, whose superclass is Data.

  • class Isbn13 < Data.define(:digits) creates an anonymous class (the Data.define call) and a named subclass of it. The ancestors become Isbn13, an unnamed class, Data, Object. The extra layer shows up in ancestors, in backtraces and in superclass chains, and it gives you nothing in return. RuboCop’s Style/DataInheritance flags the subclass form (pending cop, enabled with NewCops: enable), and Style/StructInheritance does the same for Struct.

  • A class that is never assigned to a constant is anonymous: anonymous.name is nil and it prints #<data digits=...>. Module#set_temporary_name (Ruby 3.3) gives it a name for inspection only; a temporary name cannot be a valid constant name ("fake<Isbn>" is accepted, "Isbn" would not be).

  • The block is evaluated with module_eval semantics, so def, include, constants and private work as in a class body. A constant defined in the block is defined on the surrounding scope, not on the new class, because blocks do not open a new constant scope; use self::NAME or an explicit Isbn10::NAME = outside when a constant must live on the class.

When a Data class has a lot of code, give it its own file and write Foo = Data.define(...) do there with the methods inside, as shelf/book.rb does. Both forms define the same class; the block form has the cleaner hierarchy.

Value objects

A value object is defined by its contents, not by its identity: two Shelf::Isbn objects with the same digits are the same ISBN for every purpose. A value object is immutable, small, validated at construction and compared by value. Data provides all four properties out of the box, which is why it is the default choice for modelling a domain primitive (an ISBN, a money amount, a coordinate, an email address).

Equality, hash and freezing

Data and Struct define ==, eql? and hash from the members, so value objects are interchangeable as Hash keys, Set members and arguments to uniq, tally and group_by.

# frozen_string_literal: true

require "shelf"

herbert = Shelf::Author.new("Frank Herbert")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
dune = Shelf::Book.new(isbn:, title: "Dune", author: herbert)
copy = Shelf::Book.new(isbn: Shelf::Isbn.new("9780306406157"), title: "Dune", author: herbert)

p dune.equal?(copy)
p dune == copy
p dune.eql?(copy)
p dune.hash == copy.hash

p [dune, copy].uniq.size
p [dune, copy].to_set.size
p({ dune => :on_loan }[copy])
p [dune, copy].tally.values

# A plain class gets none of this for free.
class PlainBook
  attr_reader :title

  def initialize(title) = @title = title
end

first = PlainBook.new("Dune")
second = PlainBook.new("Dune")
p first == second
p [first, second].uniq.size

# Value equality needs the same class and equal members.
Other = Data.define(:isbn, :title, :author)
p dune == Other.new(isbn:, title: "Dune", author: herbert)
p dune == Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new("F. Herbert"))

# Members are compared with == for ==, and with eql? for eql? and hash.
Pair = Data.define(:left, :right)
p Pair.new(1, 2) == Pair.new(1.0, 2)
p Pair.new(1, 2).eql?(Pair.new(1.0, 2))
p({ Pair.new(1, 2) => :a }.key?(Pair.new(1.0, 2)))
false
true
true
true
1
1
:on_loan
[2]
false
2
false
false
true
false
false

Source: Data#eql?

  • dune.equal?(copy) is false (two objects) but dune == copy, dune.eql?(copy) and equal hash values make them one key: uniq and to_set count one, the Hash lookup with the copy finds :on_loan, and tally counts two of the same value.

  • A plain class gets none of this. Two PlainBook objects with the same title are different keys, == is false, and uniq keeps both until the class defines ==, eql? and hash together.

  • The class must match: a different Data class with the same members (Other) is never equal. A book whose author differs is a different value, because Author is compared by value too.

  • == compares members with ==; eql? and hash compare them with eql? and hash. Pair.new(1, 2) == Pair.new(1.0, 2) is true, the same pair with eql? is false, and so they are different Hash keys. This is the consistency rule from Equality, Hashing and Comparison: equal keys must have equal hashes, and the generated methods obey it as long as every member does.

  • Mutable members break the rule: a Struct used as a Hash key changes its hash when a member changes (see the pitfalls), and a Data with a mutable member can change the same way. Immutability is what makes the equality safe.

Because value objects are keys, they also make good group labels, as the next example shows with the shelf catalogue.

# frozen_string_literal: true

require "shelf"

herbert = Shelf::Author.new("Frank Herbert")
le_guin = Shelf::Author.new("Ursula K. Le Guin")

def book(isbn, title, author) = Shelf::Book.new(isbn: Shelf::Isbn.new(isbn), title:, author:)

catalog = Shelf::Catalog.new
catalog.add(book("9780306406157", "Dune", herbert))
catalog.add(book("9780441172719", "Dune Messiah", herbert))
catalog.add(book("9780441478125", "The Dispossessed", le_guin))

# Data objects work as Hash keys and Set members: two Author values with the
# same name are the same key, wherever they were built.
by_author = catalog.group_by(&:author)
by_author.each { |author, books| puts "#{author.name}: #{books.map(&:title).join(', ')}" }
p by_author[Shelf::Author.new("Ursula K. Le Guin")].size
p catalog.to_set(&:author).size
p catalog.map(&:author).tally.transform_keys(&:name)
p catalog.sort_by { [it.author.name, it.title] }.map(&:to_s)
p catalog.find { it.isbn == Shelf::Isbn.new("978-0-441-47812-5") }&.title
Frank Herbert: Dune, Dune Messiah
Ursula K. Le Guin: The Dispossessed
1
2
{"Frank Herbert" => 2, "Ursula K. Le Guin" => 1}
["Dune by Frank Herbert", "Dune Messiah by Frank Herbert", "The Dispossessed by Ursula K. Le Guin"]
"The Dispossessed"

The two Author objects built in different places collapse into one key. group_by(&:author) and to_set(&:author) work without any extra code, and Catalog gets them from Enumerable (see Enumerable).

Comparable for ordering only

A value object often needs an order (sort editions by year, find the oldest). The tool is Comparable plus a <=> method. The trap is that Comparable also supplies ==, and the supplied == wins over the one Data generated.

# frozen_string_literal: true

# Comparable supplies ordering. It also supplies ==, and that == wins over
# the one Data generates, because the included module sits in front of Data.
Edition = Data.define(:title, :year) do
  include Comparable

  def <=>(other) = other.is_a?(self.class) ? year <=> other.year : nil
end

dune = Edition.new("Dune", 1965)
emma = Edition.new("Emma", 1965)
p Edition.ancestors.take(3)
p Edition.instance_method(:==).owner
p dune == emma
p dune.eql?(emma)
p dune.hash == emma.hash
p [dune, emma].uniq.size
p dune.between?(dune, emma)
p dune.clamp(dune, emma)

# Fix 1: order by every member, so that equal ordering implies equal members.
Sorted = Data.define(:title, :year) do
  include Comparable

  def <=>(other) = other.is_a?(self.class) ? [year, title] <=> [other.year, other.title] : nil
end
p Sorted.new("Dune", 1965) == Sorted.new("Emma", 1965)
p [Sorted.new("Emma", 1965), Sorted.new("Dune", 1965), Sorted.new("Alice", 1900)].sort.map(&:title)

# Fix 2: keep the partial order but restore the generated ==.
Restored = Data.define(:title, :year) do
  include Comparable

  def <=>(other) = other.is_a?(self.class) ? year <=> other.year : nil

  def ==(other) = Data.instance_method(:==).bind_call(self, other)
end
p Restored.new("Dune", 1965) == Restored.new("Emma", 1965)
p Restored.new("Dune", 1965) < Restored.new("Emma", 1966)

# Fix 3: do not include Comparable; sort with an explicit key.
p [emma, dune].sort_by { [it.year, it.title] }.map(&:title)
p [emma, dune].min_by(&:year).title
[Edition, Comparable, Data]
Comparable
true
false
false
2
true
#<data Edition title="Dune", year=1965>
false
["Alice", "Dune", "Emma"]
false
true
["Dune", "Emma"]
"Emma"

Source: Comparable#==

  • include Comparable inside the block puts Comparable in front of Data in the ancestors (Edition, Comparable, Data), so Edition.instance_method(:==).owner is Comparable. Its == returns true whenever <=> returns 0.

  • Ordering by year alone makes two different editions "equal": dune == emma is true while dune.eql?(emma) is false and their hashes differ. The value object now breaks the rule that equal objects share a hash: uniq keeps both, a Hash treats them as two keys, and between?/clamp consider them interchangeable.

  • Three fixes, in order of preference. Order by all members ([year, title] <=> [other.year, other.title]) so that a tie means equality. Keep the partial order and restore the generated == with Data.instance_method(:==).bind_call(self, other). Or do not include Comparable and sort with an explicit key (sort_by { [it.year, it.title] }, min_by(&:year)), which keeps == meaning "same value" and ordering meaning "for this sort".

  • Return nil from <=> for a different class so < raises ArgumentError ("comparison of Edition with 3 failed") rather than comparing nonsense, and == answers false.

  • Struct and Data do not define <=>. Sorting a collection of them without a key raises ArgumentError.

Design rules for value objects

  • Keep them small and named for the domain. Shelf::Isbn is better than a String with a convention, because the constructor can reject a malformed one and the type names the concept.

  • Validate in initialize, once. Everything that holds an Isbn can rely on it being valid.

  • Normalise on the way in (strip hyphens, Float(amount)), so equal values have equal representations.

  • Return new objects, never change. with and your own methods (isbn.with(digits: ...), price.add(other)) produce copies.

  • Override to_s for display and leave inspect alone. Shelf::Book#to_s prints "Dune by Frank Herbert"; inspect still prints every member for debugging. Do not make to_s return something that is not a String: interpolation falls back to the default #<Class:0x...> form.

  • Keep behaviour that belongs to the value on the value (formatting, arithmetic, predicates), and I/O and persistence outside it. A value object that talks to a database is a different pattern; see Application Architecture in Plain Ruby.

  • Use them to replace parameter lists. A method that takes lat, lng pairs in four places should take a Coordinate; a parameter object also makes keyword arguments unnecessary at the call site. See Methods and Parameters.

Pattern matching with Struct and Data

Both classes support case/in. A hash pattern calls deconstruct_keys, an array pattern calls deconstruct, and a Constant(...) or Constant[...] pattern checks the class first (with ===) and then applies the inner pattern.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn:, title: "Dune", author:)
Reading = Struct.new(:book, :page)
reading = Reading.new(book, 120)

# Hash patterns work on Data and Struct through deconstruct_keys.
case book
in { title:, author: { name: } }
  puts "#{title} by #{name}"
end

# Constant(...) checks the class first.
case book
in Shelf::Book[title:, author: Shelf::Author[name: "Frank Herbert"]]
  puts "#{title} is by Herbert"
end

# Array patterns work through deconstruct.
case reading
in [Shelf::Book[title:], Integer => page] if page > 100
  puts "#{title}: past page 100 (#{page})"
end

# Rightward assignment extracts members, and raises when the shape does not match.
book => { isbn: { digits: } }
puts digits

string_title = (book in { title: String })
integer_title = (book in { title: Integer })
p string_title, integer_title

begin
  author => { name: Integer }
rescue NoMatchingPatternError => e
  puts "#{e.class}: #{e.message}"
end

begin
  author => { nickname: _ }
rescue NoMatchingPatternKeyError => e
  puts "#{e.class}: #{e.message}"
  p e.key
end
Dune by Frank Herbert
Dune is by Herbert
Dune: past page 100 (120)
9780306406157
true
false
NoMatchingPatternError: #<data Shelf::Author name="Frank Herbert">: Integer === "Frank Herbert" does not return true
NoMatchingPatternKeyError: #<data Shelf::Author name="Frank Herbert">: key not found: :nickname
:nickname
  • in { title:, author: { name: } } matches a Data through deconstruct_keys, and recurses into the nested Author, binding title and name as local variables. A hash pattern matches a subset of the keys: extra members are ignored.

  • in Shelf::Book[title:, author: Shelf::Author[name: "Frank Herbert"]] first checks Shelf::Book === book, so a hash with the same keys would not match, then matches the author’s name against a literal.

  • in [Shelf::Book[title:], Integer => page] if page > 100 is an array pattern on a Struct (it calls deconstruct), with a class check, a binding (Integer => page) and a guard. Positional patterns depend on member order, so a hash pattern is the safer default for a record whose members may be reordered.

  • book => { isbn: { digits: } } is the one-line rightward form: it binds the variables and raises when the pattern does not match, which makes it an assertion plus an extraction. It raises NoMatchingPatternError for a value mismatch ("Integer === "Frank Herbert" does not return true") and NoMatchingPatternKeyError, a subclass, for a missing key; e.key returns the missing key.

  • book in { title: String } is the boolean form. Because in binds more loosely than assignment, put it in parentheses when you assign the result: ok = (book in { title: String }) (without the parentheses Ruby parses (ok = book) in ...).

  • deconstruct_keys(keys) receives the keys the pattern needs (or nil for a pattern with **rest) so a custom implementation can skip work; the generated one returns the requested members.

The pattern language in full (find patterns, alternatives, pinning, deconstruct for your own classes) is in Pattern Matching. The Loan class in the shelf gem drives its state with the same hash patterns.

Converting to and from Hash, Array and JSON

Boundaries are where records are made: parsed JSON, a database row, a command line. The conversions are small, and the only rule is to be explicit about nesting.

# frozen_string_literal: true

require "json"
require "shelf"

herbert = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "Dune", author: herbert)

# To a Hash: to_h is shallow, so nested values stay objects.
p book.to_h
p herbert.to_h

# From a Hash: splat the keys.
attrs = { name: "Ursula K. Le Guin" }
p Shelf::Author.new(**attrs)

# Extra keys fail: slice to the members first when the source is not trusted.
raw = { name: "Ursula K. Le Guin", born: 1929 }
begin
  Shelf::Author.new(**raw)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end
p Shelf::Author.new(**raw.slice(*Shelf::Author.members))

# JSON: symbolize the keys, then build the nested objects explicitly.
json = '{"isbn":"978-0-306-40615-7","title":"Dune","author":{"name":"Frank Herbert"}}'
data = JSON.parse(json, symbolize_names: true)
parsed = Shelf::Book.new(
  isbn: Shelf::Isbn.new(data[:isbn]),
  title: data[:title],
  author: Shelf::Author.new(**data[:author])
)
p parsed == book

# Back to JSON: serialize to_h, or define the object's own shape.
puts herbert.to_h.to_json
puts JSON.generate({ title: book.title, author: book.author.name })

# Struct and Data to arrays.
Reading = Struct.new(:title, :page)
title, page = *Reading.new("Dune", 120)
p [title, page]
p Reading.new("Dune", 120).to_a
p herbert.respond_to?(:to_a)
{isbn: #<data Shelf::Isbn digits="9780306406157">, title: "Dune", author: #<data Shelf::Author name="Frank Herbert">}
{name: "Frank Herbert"}
#<data Shelf::Author name="Ursula K. Le Guin">
ArgumentError: unknown keyword: :born
#<data Shelf::Author name="Ursula K. Le Guin">
true
{"name":"Frank Herbert"}
{"title":"Dune","author":"Frank Herbert"}
["Dune", 120]
["Dune", 120]
false

Source: Data#to_h

  • to_h is shallow: nested Data values stay objects (isbn: #<data Shelf::Isbn ...>). Convert recursively with your own method when you need plain data, and write the inverse (from_h) next to it.

  • Klass.new(**hash) is the inverse of to_h for flat records, and the documentation points out the symmetry (Measure.new(**distance.to_h) == distance). Keys must be symbols for the double splat.

  • An extra key is an error, not a silent ignore: ArgumentError ("unknown keyword: :born"). Slice to Klass.members when the source is not under your control. A missing key is an ArgumentError too.

  • JSON.parse(text, symbolize_names: true) produces the symbol keys the double splat needs. Build nested values explicitly (Shelf::Isbn.new(data[:isbn])) so validation runs at the boundary.

  • A struct splats to an array (title, page = *reading); a Data does not respond to to_a. Use pattern matching, to_h or an explicit method.

  • Serialise through to_h (herbert.to_h.to_json) or hand-write the wire format. Marshal.dump works for both (and keeps the class), but is for trusted data only. See Serialization: JSON, YAML, CSV and Marshal.

Data versus Struct

The two classes share an implementation but not an interface. The difference is most visible when asking the classes themselves.

# frozen_string_literal: true

# What Struct has that Data does not (and the reverse), straight from the classes.
struct_only = Struct.instance_methods(false) - Data.instance_methods(false)
data_only = Data.instance_methods(false) - Struct.instance_methods(false)
shared = Struct.instance_methods(false) & Data.instance_methods(false)
p struct_only.sort
p data_only.sort
p shared.sort
p Struct.include?(Enumerable)
p Data.include?(Enumerable)
p Struct.singleton_methods.sort
p Data.singleton_methods.sort
[:[], :[]=, :dig, :each, :each_pair, :filter, :length, :select, :size, :to_a, :values, :values_at]
[:with]
[:==, :deconstruct, :deconstruct_keys, :eql?, :hash, :inspect, :members, :to_h, :to_s]
true
false
[:new]
[:define]

Source: Struct, Data

Struct Data

Mutability

Mutable: writers, []=. freeze is optional and shallow.

Immutable: no writers; frozen at construction. Members that are mutable objects are not frozen.

Required members

None: a missing member is nil.

All: a missing member is an ArgumentError.

Construction

Positional or keywords (3.2); keyword_init: for strictness.

Positional or keywords, converted to keywords for initialize; [] is an alias for new.

Collection behaviour

Enumerable, each, each_pair, to_a, values, [], dig, size, values_at, select.

None.

Copy with changes

dup, then use the writers.

with(**changes); re-runs initialize.

Pattern matching

deconstruct, deconstruct_keys.

deconstruct, deconstruct_keys.

Equality

==, eql?, hash on class and members.

The same.

Introduced

The original core class (Ruby 1.x).

Ruby 3.2.

Best at

Mutable bags of settings, accumulators, tuples that need indexing or iteration.

Values: identifiers, measures, coordinates, results, events.

When in doubt about a record that holds data and does not change, start with Data. Move to Struct when you find yourself writing with in a loop to emulate an accumulator, or when a collection interface (each, []) is the point.

Allocations

A frequent worry is cost. The question is how many objects each form creates per instance, since allocation, not method dispatch, dominates short-lived records. The example counts allocated objects over 1000 instances with the GC disabled so that collections do not interfere.

# frozen_string_literal: true

PlainBook = Class.new do
  attr_reader :isbn, :title, :year

  def initialize(isbn:, title:, year:)
    @isbn = isbn
    @title = title
    @year = year
  end
end
StructBook = Struct.new(:isbn, :title, :year)
KeywordStructBook = Struct.new(:isbn, :title, :year, keyword_init: true)
DataBook = Data.define(:isbn, :title, :year)

def allocations(times = 1000, &)
  GC.disable
  before = GC.stat(:total_allocated_objects)
  times.times(&)
  GC.stat(:total_allocated_objects) - before
ensure
  GC.enable
end

isbn = "9780306406157"
rows = {
  "Hash literal" => allocations { { isbn:, title: "Dune", year: 1965 } },
  "Plain class, keywords" => allocations { PlainBook.new(isbn:, title: "Dune", year: 1965) },
  "Struct, positional" => allocations { StructBook.new(isbn, "Dune", 1965) },
  "Struct, keywords" => allocations { StructBook.new(isbn:, title: "Dune", year: 1965) },
  "Struct, keyword_init" => allocations { KeywordStructBook.new(isbn:, title: "Dune", year: 1965) },
  "Data, positional" => allocations { DataBook.new(isbn, "Dune", 1965) },
  "Data, keywords" => allocations { DataBook.new(isbn:, title: "Dune", year: 1965) }
}
rows.each { |name, count| puts format("%-22<name>s %6<count>d", name:, count:) }
Hash literal             1002
Plain class, keywords    1004
Struct, positional       1004
Struct, keywords         2003
Struct, keyword_init     2004
Data, positional         3004
Data, keywords           3002

Source: GC.stat

  • A Hash literal and a plain object cost about one object per instance (plus a handful of one-off allocations that appear in the thousand-instance total). A positional Struct costs the same.

  • A Struct built from keywords costs two: the keyword arguments are collected into a Hash that the struct then reads.

  • A Data costs three, whether built positionally or from keywords: the documented conversion of every call to keyword arguments for initialize allocates extra objects.

The numbers belong to Ruby 4.0.7’s implementation and can change between releases; count on your own Ruby version before tuning. They show a relative order, not a verdict. A few extra short-lived objects per record are invisible next to a database call or a JSON parse, and they cost less than a single bug caused by a mutable value. Reach for a Hash or a positional Struct in a measured hot loop, and keep Data as the default for values. See Performance, Benchmarking and Profiling for measuring properly and Garbage Collection and Memory for what the allocations cost.

OpenStruct

OpenStruct builds an object whose members are whatever keys you give it, plus any you assign later. It was convenient for ad hoc records and is the wrong tool today, so this page shows no OpenStruct code; older code that still uses it is covered, with its replacement, in What’s New and Migrating Older Code. Since Ruby 4.0 it is a bundled gem: it is installed with Ruby but is not a default gem, so under Bundler require "ostruct" raises LoadError unless the Gemfile lists gem "ostruct".

  • Members are created on assignment and reading an unknown one returns nil, so a typo is a silent nil, exactly the failure mode of a Hash.

  • A key can shadow a built-in method (a class: or method: key), which is why the gem defines class! and method! aliases for the originals.

  • Every key becomes a method, and method names are never garbage collected. Building an OpenStruct from untrusted input (a JSON request body) lets a client grow the process’s symbol and method tables: the gem’s own documentation calls it a potential denial of service. Use a Hash for external keys.

  • It is slow: the ostruct gem documentation describes small-hash creation and access as up to 200 times slower than the Hash, and with ruby -W:performance (see Warning) every instantiation prints "OpenStruct use is discouraged for performance reasons".

A Struct with keyword_init: true gives named members with typo detection; a Data gives an immutable one; a Hash fits keys from outside. RuboCop’s Style/OpenStructUse flags every use. For tests, a double from the test framework replaces ad hoc OpenStruct objects (see RSpec and Test Doubles). See RubyGems and Bundler for how bundled gems load.

Struct pitfalls

# frozen_string_literal: true

Point = Struct.new(:x, :y)

# Pitfall 1: a mutable Struct used as a Hash key.
point = Point.new(1, 2)
visited = { point => :start }
point.x = 9
p visited[point]
p visited.rehash[point]

# Pitfall 2: structurally identical classes are never equal.
Cell = Struct.new(:x, :y)
p Point.new(1, 2) == Cell.new(1, 2)

# Pitfall 3: mixing positional and keyword arguments is not an error; the keywords become a Hash.
p Point.new(1, y: 2)

# Pitfall 4: member names that clash with built-in methods.
Overrides = Struct.new(:hash, :size) # rubocop:disable Lint/StructNewOverride
item = Overrides.new(1, 2)
p item.size
p item.hash == 1

# Pitfall 5: a String as first argument defines a constant under Struct.
Struct.new("Pair", :left, :right)
p Struct::Pair.new(1, 2)

# Pitfall 6: subclassing the generated class adds an anonymous parent.
class Legacy < Struct.new(:digits) # rubocop:disable Style/StructInheritance
  def to_s = digits
end

p Legacy.superclass.name
p Legacy.ancestors.take(3).map(&:name)
nil
:start
false
#<struct Point x=1, y={y: 2}>
2
true
#<struct Struct::Pair left=1, right=2>
nil
["Legacy", nil, "Struct"]

Source: Struct

  • A mutable Struct as a Hash key. Changing point.x changed its hash, so visited[point] found nothing until rehash. Freeze a struct before using it as a key, or use a Data.

  • Same shape, different class. Two struct (or data) classes with identical members are never equal. This is deliberate and avoids the "any record with two numbers" confusion, but it surprises tests that build structs through different factories.

  • Mixing positional and keyword arguments. Point.new(1, y: 2) is not an error: y: 2 becomes a Hash stored in y. keyword_init: true turns this into an ArgumentError.

  • Members that clash with built-in methods. Struct.new(:hash, :size) overrides hash and size: item.hash == 1 breaks Hash and Set behaviour. RuboCop’s Lint/StructNewOverride flags the offending members. Name them differently.

  • A String as the first argument defines a constant under Struct. Struct.new("Pair", :left, :right) creates Struct::Pair, a global name; creating it twice prints "redefining constant Struct::Pair", and the String must be a constant name (a lowercase one raises NameError). Assign the class to your own constant instead.

  • Subclassing the generated class. class Legacy < Struct.new(:digits) adds an anonymous parent between Legacy and Struct, as shown for Data earlier; use the block form. RuboCop’s Style/StructInheritance flags it.

Where older books and tutorials are outdated

Older teaching Current behaviour

class Money < Struct.new(:amount) or class Money < Data.define(:amount)

An extra anonymous superclass. Write Money = Data.define(:amount) do ... end (Style/StructInheritance, Style/DataInheritance).

Struct.new(..., keyword_init: true) is the way to build a struct from keywords

Not needed since Ruby 3.2: the default accepts keywords. Keep it only for strictness (Style/RedundantStructKeywordInit flags it).

"Use a Struct for immutable records" (with freeze in initialize)

Data.define (Ruby 3.2) is immutable by construction, with with, all-required members and no writers.

OpenStruct for ad hoc objects, configuration and test fixtures

A bundled gem in Ruby 4.0 (not loadable under Bundler without a Gemfile entry), slow, and a denial-of-service risk with untrusted keys. Use Struct, Data or Hash.

Hash#inspect as {:a=>1} and error messages with a backtick

{a: 1} and 'method' since 3.4; the output in this page is the current format.

require "set" before using Set of value objects

Set is a core class in 4.0 and autoloaded since 3.2.

Value objects written by hand with attr_reader, ==, eql? and hash

Data generates all of them, correctly and consistently. Hand-write only when equality is not "all members".

Comparable is the way to order a record

Still true, but include Comparable in a Data block replaces the generated ==; order by all members or restore it (see above).

A Data has to_a and each like a Struct

It has neither. It has deconstruct, deconstruct_keys, to_h and with.

Common exceptions and warnings

Exception or warning Cause

ArgumentError ("struct size differs")

More positional values than Struct members.

ArgumentError ("unknown keywords: chapter")

A keyword that is not a Struct member. Data says "unknown keyword: :alt".

ArgumentError ("missing keyword: :lng", "missing keywords: :lat, :lng")

A Data constructed without all members.

ArgumentError ("wrong number of arguments (given 3, expected 0..2)")

Too many positional values for a Data.

ArgumentError ("wrong number of arguments (given 2, expected 0)")

Positional arguments for a keyword_init: true struct, or positional plus keyword mixed for a Data, or a positional argument to Data#with.

ArgumentError ("duplicate member: lat", "invalid data member: lat=")

Data.define with a repeated member or a setter-style name.

ArgumentError ("comparison of Edition with 3 failed")

<, > or clamp against an object of another class when <=> returns nil; also sorting records without <=>.

NoMethodError ("undefined method 'lat=' for an instance of Coordinate")

Writing a Data member.

NoMethodError ("undefined method 'new' for class Data")

Instantiating Data itself.

FrozenError ("can’t modify frozen Coordinate: …​")

instance_variable_set or `@x

=` on a Data instance; a setter on a frozen struct.

NameError ("no member 'chapter' in struct")

struct[:chapter] with an unknown member name.

IndexError ("offset 5 too large for struct(size:2)")

struct[5] out of range (and "too small" for a negative one).

NameError ("identifier lower needs to be constant")

A lowercase String as the first argument of Struct.new.

NoMatchingPatternError, NoMatchingPatternKeyError

value => pattern that does not match (the second for a missing key, with #key).

KeyError ("key not found: :titel")

Hash#fetch, the safer sibling of reading an unknown Hash key.

LoadError ("cannot load such file — ostruct")

require "ostruct" under Bundler without a Gemfile entry (Ruby 4.0).

warning: redefining constant Struct::Pair

Calling Struct.new("Pair", ...) twice.

warning: OpenStruct use is discouraged for performance reasons

Creating an OpenStruct with ruby -W:performance.

Pitfalls

  • A frozen Data is not deeply immutable. Copy and freeze the Arrays, Hashes and Strings it holds, or Ractor.make_shareable the result.

  • include Comparable replaces the generated ==. Order by every member, restore ==, or sort with a key.

  • Struct as a Hash key or Set member. It is mutable; freeze it or use Data.

  • Overriding initialize in a Struct with positional parameters disables keyword construction. Forward (*args, **kwargs) if both must work.

  • Overriding initialize in a Data with positional parameters is wrong: it always receives keywords. Declare keyword parameters and call super(...) with keywords.

  • Memoizing in a Data. The object is frozen; compute in initialize before super, or compute on demand.

  • Putting the derived value in a hidden instance variable and expecting equality to see it. It does not; add a member.

  • Subclassing the generated class. Prefer the block form.

  • Using OpenStruct for convenience. A Hash or a Struct does the same job without the cost and the risk.

  • Mixing Struct.new("Name") constants into your namespace. The class goes under Struct, globally.

  • Positional patterns on a record whose members may be reordered. Use hash patterns, or fix the order in a comment and a test.

  • ok = value in pattern assigns before matching. Use parentheses.

  • to_h is shallow. A nested Data stays an object in the Hash; convert recursively if you need plain data.

  • Treating Data#with as a setter. It returns a new object and leaves the original unchanged; ignoring the return value is a silent no-op that is easy to miss in review.

RuboCop cops for this page

Cop What it checks

Style/StructInheritance

class Foo < Struct.new(...): use Foo = Struct.new(...) do ... end. Enabled by default.

Style/DataInheritance

class Foo < Data.define(...): use the block form. A pending cop (1.49), enabled by NewCops: enable.

Style/OpenStructUse

Any use of OpenStruct (pending cop, 1.23). Use Struct, Data or Hash instead.

Lint/StructNewOverride

A Struct.new member that overrides a Struct method (hash, size, to_a, …​).

Style/RedundantStructKeywordInit

keyword_init: true or nil on Ruby 3.2 and later. Disabled by default, unsafe to autocorrect (true also rejects positional arguments).

Style/SelectByKind

reading.select { it.is_a?(Integer) }: prefer grep(Integer) (handy with Struct being Enumerable).

Lint/BinaryOperatorWithIdenticalOperands

Foo.new(1) == Foo.new(1) in tests of value equality: build the two objects into variables first.

The wider style tooling is in Style, Linting and Static Analysis; the idioms around records and parameter objects are in Idioms, Style and Clean Code. For typing records with RBS, see Type Checking with RBS, TypeProf, Steep and Sorbet.

Version notes

Version Change

3.0

Pattern matching is stable, and Struct supports it through deconstruct and deconstruct_keys. The OpenStruct documentation marks its use as officially discouraged.

3.1

Struct.keyword_init? is added. Passing only keyword arguments to a struct without keyword_init: warns, as a step toward the 3.2 behaviour. Hash shorthand ({title:, author:}) shortens record literals.

3.2

Data (immutable value objects, Data.define, with). Struct.new accepts keyword arguments without keyword_init: true. Set autoloads without require.

3.3

Module#set_temporary_name names an anonymous Data or Struct class for inspection. Data.define is available from C (rb_data_define).

3.4

Hash#inspect prints {name: "Ada"}, so Data#to_h and Struct#to_h read that way; it as the implicit block parameter; error messages use 'method'.

4.0

Set is a core class (so a Set of Data values needs no require). ostruct is a bundled gem: it needs a Gemfile entry under Bundler.