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