Data.define: Ruby's Immutable Value Objects
You need a small object to hold a few related values. A point with an x and a y. A money amount with a currency. A date range with a start and an end. In most languages you’d reach for a record or a value object. In Ruby, for years, the answer was Struct, and Struct always came with a catch: it’s mutable, its equality rules surprise people, and it carries array-like behavior you rarely want.
Ruby 3.2 fixed this with Data.define. It’s a built-in factory for immutable value objects, and once you’ve used it, most of your tiny Structs and hand-written value classes start to look like wasted lines.
Point = Data.define(:x, :y)
origin = Point.new(x: 0, y: 0)
origin.x # => 0
origin.y # => 0
That’s the whole thing. Three words and you have a proper value object.
What you get for free
A Data class arrives with the pieces you’d otherwise write by hand:
Point = Data.define(:x, :y)
a = Point.new(x: 1, y: 2)
b = Point.new(x: 1, y: 2)
a == b # => true (value equality, not identity)
a.eql?(b) # => true
a.hash == b.hash # => true (usable as hash keys / in sets)
a.to_h # => {x: 1, y: 2}
a.inspect # => "#<data Point x=1, y=2>"
Value equality is the headline. Two points with the same coordinates are equal, which is almost always what you want from a value object. Because hash and eql? are consistent, instances work correctly as hash keys and in a Set with no extra effort.
counts = Hash.new(0)
counts[Point.new(x: 0, y: 0)] += 1
counts[Point.new(x: 0, y: 0)] += 1
counts # => {#<data Point x=0, y=0>=>2}
Both increments land on the same key, because the two points are equal.
Positional or keyword, your choice
Data.define accepts both calling styles, which is handy when you’re migrating from Struct or matching an existing constructor:
Point = Data.define(:x, :y)
Point.new(1, 2) # positional
Point.new(x: 1, y: 2) # keyword
I lean on the keyword form because it’s self-documenting at the call site. Point.new(x: 1, y: 2) tells you what each number means; Point.new(1, 2) makes you go look.
Immutability is the point
The name is not an accident. A Data instance is frozen. There are no setters, and you cannot reassign its members:
p = Point.new(x: 1, y: 2)
p.x = 5
# => NoMethodError: undefined method 'x=' for an instance of Point
p.frozen? # => true
This is the big difference from Struct, which hands you mutable instances by default:
PointStruct = Struct.new(:x, :y)
s = PointStruct.new(1, 2)
s.x = 99 # => 99, no complaint
Mutable value objects are a quiet source of bugs. You pass one into a method, the method changes a field, and now a value somewhere else has shifted underneath you. Immutability removes that entire category of problem. If two pieces of code share a Point, neither can surprise the other.
Changing values: with
Immutable doesn’t mean inconvenient. When you need a modified copy, with returns a new instance with some members replaced and the rest carried over:
p = Point.new(x: 1, y: 2)
moved = p.with(y: 10)
moved # => #<data Point x=1, y=10>
p # => #<data Point x=1, y=2> (original untouched)
This is the functional-update pattern: you never mutate, you derive. It reads cleanly in transformations:
Money = Data.define(:cents, :currency)
price = Money.new(cents: 1000, currency: "USD")
discounted = price.with(cents: price.cents - 200)
with only accepts members that exist, so a typo fails loudly instead of silently adding garbage:
price.with(dollars: 8)
# => ArgumentError: unknown keyword: :dollars
Adding behavior
A value object usually wants a few methods of its own. Data.define takes a block, and inside it you’re defining a normal class:
Temperature = Data.define(:celsius) do
def fahrenheit
celsius * 9.0 / 5 + 32
end
def freezing?
celsius <= 0
end
end
t = Temperature.new(celsius: 25)
t.fahrenheit # => 77.0
t.freezing? # => false
You can also override the constructor to validate or normalize input. The trick is to call super with the cleaned-up values:
Percentage = Data.define(:value) do
def initialize(value:)
raise ArgumentError, "out of range: #{value}" unless (0..100).cover?(value)
super(value: value)
end
end
Percentage.new(value: 50) # => #<data Percentage value=50>
Percentage.new(value: 150) # => ArgumentError: out of range: 150
Because the object is frozen after construction, this is your one chance to enforce invariants. After that, the value is guaranteed valid for its whole life.
Data vs Struct: when to use which
Data is not a wholesale replacement for Struct. They aim at different things now:
-
Reach for
Datawhen you want an immutable value object: coordinates, money, ranges, configuration snapshots, parsed results, anything whose identity is its values. This is the common case, and it should be your default. -
Reach for
Structwhen you genuinely need mutability, or you want the array-like behavior (s[0],s.to_a, deconstruction into positional slots) thatStructprovides andDatadeliberately omits.
If you’ve read the Struct vs OpenStruct post here, think of Data as the third option that’s usually better than both for value-style data: it has Struct’s performance and clarity without OpenStruct’s overhead or Struct’s mutability.
Pattern matching works out of the box
Data objects support Ruby’s pattern matching through deconstruct and deconstruct_keys, which makes them pleasant to take apart:
Point = Data.define(:x, :y)
p = Point.new(x: 0, y: 5)
case p
in Point[x: 0, y:]
"on the y-axis at #{y}"
in Point[x:, y: 0]
"on the x-axis at #{x}"
else
"somewhere else"
end
# => "on the y-axis at 5"
You can match on the type, destructure the members you care about, and bind them to locals in one expression. It pairs especially well with parsing and dispatch code.
Where this shows up in tests
Value equality is what makes Data a joy in tests. Because two instances with the same members are equal, assertions read exactly how you’d say them out loud:
def test_midpoint
result = midpoint(Point.new(x: 0, y: 0), Point.new(x: 4, y: 6))
assert_equal Point.new(x: 2, y: 3), result
end
No comparing fields one at a time, no custom matcher. The objects are equal or they aren’t.
And when a comparison like that fails, the failure message is a dump of two to_h hashes that you have to eyeball. That’s the exact situation RubyHash was built for: paste the expected and actual hashes and see precisely which member changed, instead of scanning two nearly identical lines of #<data ...> output.
A small feature that removes a lot of code
Data.define is not flashy. It’s one method that produces a frozen class with sensible equality, a copy-with-changes helper, pattern-matching support, and a readable inspect. But that small surface replaces the hand-rolled value object you’ve probably written a dozen times: the one with the attr_readers, the custom ==, the matching hash, and the freeze in the initializer that you sometimes forgot.
If you’re on Ruby 3.2 or newer, the next time you reach for Struct to bundle a few values together, pause and ask whether those values should ever change after creation. If the answer is no, and it usually is, Data.define is the better tool.
Comparing Ruby objects or hashes in a failing test and need to spot the difference fast? Try RubyHash, paste your two hashes for a clean, side-by-side diff.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.