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.
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 |
|
|
|
|
Typo on read |
|
|
|
|
Writing |
|
|
none; |
only if you write the writer |
Missing member at construction |
not applicable |
|
|
whatever |
Frozen |
only if you |
only if you |
always, at the end of |
only if you |
Value equality |
|
by class and members |
by class and members |
identity, unless you write it |
Collection API ( |
Yes |
Yes ( |
No |
No |
Pattern matching |
|
both |
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
Read the output in three groups.
-
A typo on read. The
Hashreturnsniland carries on with a wrong value until something fails far away;Hash#fetchfails at once withKeyError. The three class-based shapes fail at once withNoMethodError, naming the method and the class. -
Equality.
StructandDatacompare by class and members; the plain class compares by identity until you write==,eql?andhashyourself. -
A missing member at construction. A
Structfills the member withnil; aDatarefuses to exist without all its members, which is the right behaviour for a value. A plain class decides in its owninitialize.
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.membersandmark.membersreturn 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 raiseArgumentError("struct size differs"); an unknown keyword raisesArgumentError("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 aNameErrorand a position out of range is anIndexError.[]=and the accessor writers change the struct in place. -
Conversion:
to_a(aliasesdeconstructandvalues) returns the values;to_hreturns a Hash keyed by member name and accepts a block that returns key/value pairs. -
Inspection:
inspectandto_sprint#<struct Bookmark title="Dune", page=120>, the class name followed byname=valuepairs. 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
Source: Struct.keyword_init?
keyword_init: |
Behaviour |
|---|---|
|
Keywords only ( |
|
Keywords only. Positional arguments raise |
|
Never keywords: |
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>
Source: Struct#each_pair
-
Override
initializeto validate or default, and callsuperto store the values. A baresuperpasses the method’s own arguments on, including the defaults. -
An
initializewith positional parameters replaces the keyword form.Reading.new(title: "Emma")stored the Hash intitle, because the newinitializedoes not accept keywords. Either keep the constructor positional, or accept(*args, **kwargs)and forward both. -
Enumerableover the values is rarely what a record needs.reading.sumorreading.maxwork only when every member is comparable or numeric; the example restrictssumto the integers. Useto_hwhen you need names. -
freezemakes the struct read-only: the setters then raiseFrozenError(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 |
|---|---|
|
Defines the class. A String as the first argument defines the constant under |
|
Member names as symbols. |
|
Read or write by Symbol, String or Integer position. |
|
The values, in member order. |
|
A Hash by member name; the block maps each pair. |
|
Iterate values, or name/value pairs (enumerators without a block). |
|
Selected values; nested lookup. |
|
The values for which the block is true. |
|
The number of members. |
|
Value comparison: same class and equal members ( |
|
A Hash for hash patterns. |
|
|
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:
newand[]accept positional arguments or keyword arguments, but not both. The positional form is converted to keywords beforeinitializeruns, soinitializealways receives keywords. -
Every member is required. A missing one raises
ArgumentError("missing keyword: :lng", or "missing keywords: :lat, :lng"); an unknown keyword raisesArgumentError("unknown keyword: :alt"); too many positional values raiseArgumentError("wrong number of arguments (given 3, expected 0..2)"). A positional value mixed with keywords is also anArgumentError. -
No writers.
here.lat = 0is aNoMethodErrorbecause the setter does not exist;instance_variable_setis aFrozenErrorbecause the object is frozen.frozen?istruefor every instance. -
withreturns a copy with some members replaced:here.with(lat: 42.0). Called without arguments it returnsself. It accepts only keywords (a positional argument is anArgumentError) and only existing members (an unknown keyword is anArgumentError). The copy is built throughnew, soinitializeand 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.definewith no members is allowed and useful for marker values such asNotFound = Data.define. -
Dataitself cannot be instantiated:Data.newis aNoMethodError; the class method onDataisdefine. -
Conversion and inspection:
to_h(andto_h { ... }) returns a Hash;memberslists the names;inspectandto_sprint#<data Coordinate lat=41.65, lng=-0.88>. Unlike a struct, aDatahas noto_a,each,[]orEnumerable, but it does definedeconstruct(array patterns) anddeconstruct_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,[]andwith.isbn.with(digits: "123")fails exactly asShelf::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-onlyinitialize, becausenewhas 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 beforesuper; the stored member is the converted value, and an invalid one raises the usualArgumentErrorfromKernel#Float. See Conversions and Coercion for theInteger()andFloat()family. -
Call
super(...)with keywords, not positionally: the inheritedinitializetakes keywords only. -
Raise
ArgumentErrorfor bad input, as the keyword check does, and keep I/O (files, network, clocks) out ofinitialize: 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
Source: Ractor.shareable?
-
shelved.frozen?istrue, yetshelved.tags << "desert"changed the value thatshelvedrepresents. Anything that storedshelvedas a Hash key now holds a key whosehashchanged. -
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.safeis immune tosource << "desert"and rejectssafe.tags << "x"with aFrozenError. -
Ractor.shareable?is a quick deep-immutability test: it isfalseforshelved(a mutable Array inside) andtrueforsafe.Ractor.make_shareablefreezes an object graph in place. See Object Lifecycle, Freezing and Copying and Ractors. -
Members that are themselves
Datavalues (Shelf::Bookholding aShelf::Isbnand aShelf::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:
inspectomits it, and==,eql?,hashandto_hignore 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 ... endcreates one class, namedIsbn10, whose superclass isData. -
class Isbn13 < Data.define(:digits)creates an anonymous class (theData.definecall) and a named subclass of it. The ancestors becomeIsbn13, an unnamed class,Data,Object. The extra layer shows up inancestors, in backtraces and insuperclasschains, and it gives you nothing in return. RuboCop’sStyle/DataInheritanceflags the subclass form (pending cop, enabled withNewCops: enable), andStyle/StructInheritancedoes the same forStruct. -
A class that is never assigned to a constant is anonymous:
anonymous.nameisniland 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_evalsemantics, sodef,include, constants andprivatework 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; useself::NAMEor an explicitIsbn10::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)isfalse(two objects) butdune == copy,dune.eql?(copy)and equalhashvalues make them one key:uniqandto_setcount one, the Hash lookup with the copy finds:on_loan, andtallycounts two of the same value. -
A plain class gets none of this. Two
PlainBookobjects with the same title are different keys,==isfalse, anduniqkeeps both until the class defines==,eql?andhashtogether. -
The class must match: a different
Dataclass with the same members (Other) is never equal. A book whose author differs is a different value, becauseAuthoris compared by value too. -
==compares members with==;eql?andhashcompare them witheql?andhash.Pair.new(1, 2) == Pair.new(1.0, 2)istrue, the same pair witheql?isfalse, 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
Structused as a Hash key changes itshashwhen a member changes (see the pitfalls), and aDatawith 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"
Source: Enumerable#tally
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 Comparableinside the block putsComparablein front ofDatain the ancestors (Edition,Comparable,Data), soEdition.instance_method(:==).ownerisComparable. Its==returnstruewhenever<=>returns0. -
Ordering by
yearalone makes two different editions "equal":dune == emmaistruewhiledune.eql?(emma)isfalseand their hashes differ. The value object now breaks the rule that equal objects share a hash:uniqkeeps both, a Hash treats them as two keys, andbetween?/clampconsider 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==withData.instance_method(:==).bind_call(self, other). Or do not includeComparableand 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
nilfrom<=>for a different class so<raisesArgumentError("comparison of Edition with 3 failed") rather than comparing nonsense, and==answersfalse. -
StructandDatado not define<=>. Sorting a collection of them without a key raisesArgumentError.
Design rules for value objects
-
Keep them small and named for the domain.
Shelf::Isbnis 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 anIsbncan 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.
withand your own methods (isbn.with(digits: ...),price.add(other)) produce copies. -
Override
to_sfor display and leaveinspectalone.Shelf::Book#to_sprints "Dune by Frank Herbert";inspectstill prints every member for debugging. Do not maketo_sreturn 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, lngpairs in four places should take aCoordinate; 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
Source: Pattern Matching
-
in { title:, author: { name: } }matches aDatathroughdeconstruct_keys, and recurses into the nestedAuthor, bindingtitleandnameas 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 checksShelf::Book === book, so a hash with the same keys would not match, then matches the author’snameagainst a literal. -
in [Shelf::Book[title:], Integer => page] if page > 100is an array pattern on aStruct(it callsdeconstruct), 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 raisesNoMatchingPatternErrorfor a value mismatch ("Integer === "Frank Herbert" does not return true") andNoMatchingPatternKeyError, a subclass, for a missing key;e.keyreturns the missing key. -
book in { title: String }is the boolean form. Becauseinbinds 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 (ornilfor 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_his shallow: nestedDatavalues 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 ofto_hfor 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 toKlass.memberswhen the source is not under your control. A missing key is anArgumentErrortoo. -
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); aDatadoes not respond toto_a. Use pattern matching,to_hor an explicit method. -
Serialise through
to_h(herbert.to_h.to_json) or hand-write the wire format.Marshal.dumpworks 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]
| Struct | Data | |
|---|---|---|
Mutability |
Mutable: writers, |
Immutable: no writers; frozen at construction. Members that are mutable objects are not frozen. |
Required members |
None: a missing member is |
All: a missing member is an |
Construction |
Positional or keywords (3.2); |
Positional or keywords, converted to keywords for |
Collection behaviour |
|
None. |
Copy with changes |
|
|
Pattern matching |
|
|
Equality |
|
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
Hashliteral 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 positionalStructcosts the same. -
A
Structbuilt from keywords costs two: the keyword arguments are collected into a Hash that the struct then reads. -
A
Datacosts three, whether built positionally or from keywords: the documented conversion of every call to keyword arguments forinitializeallocates 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 silentnil, exactly the failure mode of aHash. -
A key can shadow a built-in method (a
class:ormethod:key), which is why the gem definesclass!andmethod!aliases for the originals. -
Every key becomes a method, and method names are never garbage collected. Building an
OpenStructfrom 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 aHashfor 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 withruby -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.xchanged itshash, sovisited[point]found nothing untilrehash. Freeze a struct before using it as a key, or use aData. -
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: 2becomes a Hash stored iny.keyword_init: trueturns this into anArgumentError. -
Members that clash with built-in methods.
Struct.new(:hash, :size)overrideshashandsize:item.hash == 1breaks Hash and Set behaviour. RuboCop’sLint/StructNewOverrideflags the offending members. Name them differently. -
A String as the first argument defines a constant under
Struct.Struct.new("Pair", :left, :right)createsStruct::Pair, a global name; creating it twice prints "redefining constant Struct::Pair", and the String must be a constant name (a lowercase one raisesNameError). Assign the class to your own constant instead. -
Subclassing the generated class.
class Legacy < Struct.new(:digits)adds an anonymous parent betweenLegacyandStruct, as shown forDataearlier; use the block form. RuboCop’sStyle/StructInheritanceflags it.
Where older books and tutorials are outdated
| Older teaching | Current behaviour |
|---|---|
|
An extra anonymous superclass. Write |
|
Not needed since Ruby 3.2: the default accepts keywords. Keep it only for strictness ( |
"Use a |
|
|
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 |
|
|
|
|
Value objects written by hand with |
|
|
Still true, but |
A |
It has neither. It has |
Common exceptions and warnings
| Exception or warning | Cause |
|---|---|
|
More positional values than |
|
A keyword that is not a |
|
A |
|
Too many positional values for a |
|
Positional arguments for a |
|
|
|
|
|
Writing a |
|
Instantiating |
|
|
=` on a |
|
|
|
|
|
|
A lowercase String as the first argument of |
|
|
|
|
|
|
|
Calling |
|
Creating an |
Pitfalls
-
A frozen
Datais not deeply immutable. Copy and freeze the Arrays, Hashes and Strings it holds, orRactor.make_shareablethe result. -
include Comparablereplaces the generated==. Order by every member, restore==, or sort with a key. -
Structas a Hash key orSetmember. It is mutable; freeze it or useData. -
Overriding
initializein aStructwith positional parameters disables keyword construction. Forward(*args, **kwargs)if both must work. -
Overriding
initializein aDatawith positional parameters is wrong: it always receives keywords. Declare keyword parameters and callsuper(...)with keywords. -
Memoizing in a
Data. The object is frozen; compute ininitializebeforesuper, 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
OpenStructfor convenience. AHashor aStructdoes the same job without the cost and the risk. -
Mixing
Struct.new("Name")constants into your namespace. The class goes underStruct, 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 patternassigns before matching. Use parentheses. -
to_his shallow. A nestedDatastays an object in the Hash; convert recursively if you need plain data. -
Treating
Data#withas 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 |
|---|---|
|
|
|
|
|
Any use of |
|
A |
|
|
|
|
|
|
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 |
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
|
4.0 |
|
References
-
Struct, Struct.new, Struct.keyword_init?, Struct.members and Struct#to_h
-
Data, Data.define, Data.new, Data#with, Data#to_h, Data#deconstruct_keys and Data#eql?
-
Comparable, Enumerable and Hash
-
Pattern Matching (syntax), NoMatchingPatternError and NoMatchingPatternKeyError
-
Module#set_temporary_name, Ractor.shareable?, Ractor.make_shareable and GC.stat