Struct and Data
Struct.new
Struct.new builds a class with the fields named in the call. It is the shortest way to replace a hash of fixed keys with something that has readers, an inspect and an equality rule:
S = Struct.new(:x, :y, keyword_init: true)
p S.new(x: 1)
ruby -e 'S = Struct.new(:x, :y, keyword_init: true); p S.new(x: 1)'
#<struct S x=1, y=nil>
A struct is mutable: s.x = 5 works, and s.y answers nil until it is set. The members also come with to_a, to_h, equality and a readable inspect, which is more than a hash gives for free.
Data.define
Data.define is the immutable version added in Ruby 3.2. The constructor takes positionals or keywords and there are no writers:
Point = Data.define(:x, :y)
p Point.new(x: 1, y: 2)
ruby -e 'Point = Data.define(:x, :y); p Point.new(x: 1, y: 2)'
#<data Point x=1, y=2>
Assigning a member raises, and the message says which writer is missing:
ruby -e 'Point = Data.define(:x, :y); Point.new(x: 1, y: 2).x = 3'
-e:1:in '<main>': undefined method 'x=' for an instance of Point (NoMethodError)
A Data value is frozen, compares by value and has a hash that follows it, so it works as a hash key and inside a set without any extra code. That combination is what a value object needs, and it is the reason to prefer it over a struct in new code.
Methods on the value
Both forms take a block for the body, which is where the small methods live. with returns a copy with one field changed, the immutable way to update:
Point = Data.define(:x, :y) do
def sum
x + y
end
end
p Point.new(x: 1, y: 2).sum
p Point.new(x: 1, y: 2).with(y: 3)
ruby -e 'Point = Data.define(:x, :y) do; def sum = x + y; end; p Point.new(x: 1, y: 2).sum; p Point.new(x: 1, y: 2).with(y: 3)'
3
#<data Point x=1, y=3>
A value that needs to change is replaced, not mutated: point = point.with(y: 3). That removes a whole class of bug where two variables point at one object and one of them edits it.
When a hash is enough
A hash is the right tool for data whose keys are only known at runtime: a JSON response, a row from a CSV, an options bag. A struct or a Data value is the right tool when the keys are fixed and the program writes them in code. The tell is a typo: row[:nmae] answers nil and fails later; row.nmae raises at once.
What to watch for
A struct without keyword_init: true takes
positionals, and a caller that passes keywords gets a confusing error.
Pass the option in new code, or build the class with
Data.define and skip the question.
Neither form validates its arguments. A value object that must reject a
negative amount or an empty name needs an explicit check, usually in a
factory method that the rest of the program calls instead of
new.
Next: Comparable and Enumerable in your own class
Article author: Arthur Isaev