/ Tags: RUBY 3 / Categories: RUBY

Ruby 3.2's Data Class — Immutable Value Objects Done Right

If you’ve been writing Ruby for a while, you’ve probably cobbled together something to represent simple value objects — a Struct, a frozen hash, maybe a plain class with an attr_reader and a manual ==. They work, but none of them feel quite right. Ruby 3.2 ships Data, a built-in class for creating immutable value objects, and it’s one of those additions that makes you wonder how you got by without it.

What Problem Data Solves


Value objects are everywhere in real codebases. A Money object with an amount and currency. A Point with x and y coordinates. A DateRange with a start and end. These aren’t database records — they don’t have identity, they don’t get mutated, they’re just structured data that two pieces of code agree on.

Before Data, your options were:

  • Struct — mutable by default, equality based on values, but you can set attributes after creation
  • Frozen Struct — works, but freezing happens at the instance level; you have to remember to do it
  • Plain class — verbose, requires manual ==, hash, and inspect implementations

Data gives you a purpose-built solution: immutable, value-equality semantics, and a clean API out of the box.

The Basics


Example:

Point = Data.define(:x, :y)

origin = Point.new(x: 0, y: 0)
other  = Point.new(x: 0, y: 0)

origin == other  # => true
origin.frozen?   # => true

origin.x = 5     # => FrozenError (can't modify frozen Point)

You get keyword-argument constructors, value-based equality, and frozen instances automatically. No boilerplate.

Data.define returns a new class, so you can assign it to a constant like any other class, or use it inline as an anonymous type.

Defining Methods on a Data Class


Data classes aren’t just dumb structs — you can add methods directly in the block form.

Example:

Point = Data.define(:x, :y) do
  def distance_from_origin
    Math.sqrt(x**2 + y**2)
  end

  def translate(dx:, dy:)
    with(x: x + dx, y: y + dy)
  end
end

p = Point.new(x: 3, y: 4)
p.distance_from_origin  # => 5.0
p.translate(dx: 1, dy: 0)  # => #<data Point x=4, y=4>
p.x                         # => 3 — original unchanged

The with method is key here: it creates a new instance with some values replaced, leaving the original untouched. This is the immutable update pattern that functional programmers reach for constantly. Ruby now ships it natively.

Data vs. Struct — What Actually Differs


Feature Struct Data
Mutability Mutable by default Always frozen
Equality Value-based Value-based
Keyword-only constructor Opt-in (Ruby 3.2+) Always keyword-only
with method No Yes
Positional args Supported Not supported
Intended use Lightweight mutable record Immutable value object

If you need to mutate the object after creation, use Struct. If mutation is a bug, use Data.

When Struct is still the right call

A configuration object that gets built up incrementally before being locked down is a good fit for Struct. A coordinate, a currency amount, a validated email address — those are Data territory.

Real-World Use: Domain Value Objects


Example:

Money = Data.define(:amount, :currency) do
  def to_s
    "#{currency} #{format('%.2f', amount)}"
  end

  def +(other)
    raise ArgumentError, "Currency mismatch" unless currency == other.currency
    with(amount: amount + other.amount)
  end
end

price = Money.new(amount: 19.99, currency: "USD")
tax   = Money.new(amount: 1.60, currency: "USD")
total = price + tax

puts total        # => USD 21.59
total.frozen?     # => true
price.amount      # => 19.99 — untouched

This is a real pattern from financial applications. The immutability guarantee means you can pass a Money value around your codebase without defensive copies. Whoever receives it cannot accidentally modify the original.

Pattern Matching with Data


Data objects work cleanly with Ruby’s pattern matching because they expose their members as deconstructable keys.

Example:

Point = Data.define(:x, :y)

point = Point.new(x: 3, y: -1)

case point
in { x: 0, y: 0 }
  puts "origin"
in { x:, y: } if y.negative?
  puts "below x-axis at #{x}"
in { x:, y: }
  puts "at #{x}, #{y}"
end
# => below x-axis at 3

Data and pattern matching are natural partners — both are designed around working with structured, immutable values.

Pro-Tip: When defining domain-level value objects with Data, add a valid? class method or validation in the constructor using initialize (called via super). Since Data is frozen on creation, validation in the constructor is your only chance to catch bad inputs before they propagate through your system.

Conclusion


Data fills a gap that Ruby developers have been papering over for years. It’s not a large API surface — define, new, with, and your own methods — but it’s exactly the right size for what it does. Immutable value objects with value equality, clean constructors, and first-class pattern matching support. If you’re on Ruby 3.2 or later, Struct is no longer the automatic choice for structured data. Reach for Data first and only drop back to Struct when you genuinely need mutability.

FAQs


Q1: Can I inherit from a Data class?
No. Data classes are final — you cannot subclass them. If you need shared behavior across multiple value types, extract it into a module and include it in each Data.define block.

Q2: Does Data work with JSON serialization?
Not automatically. You’ll need to implement to_h (which Data provides) and use it with JSON.generate(point.to_h). For deserialization, add a class-level .from_h or .from_json method that calls new with the parsed keys.

Q3: How is Data different from a frozen Struct?
Data is always keyword-only, has the with method for producing modified copies, and signals intent more clearly — anyone reading the code knows this is a value object, not a mutable record that someone forgot to freeze.

Q4: Can I add private methods to a Data class?
Yes. The block passed to Data.define is evaluated in the class context, so you can use private, define helper methods, include modules, and do anything else you’d do in a regular class definition.

Q5: What Ruby version do I need?
Data was introduced in Ruby 3.2.0. If you’re on 3.0 or 3.1, Struct with .new.freeze is the closest alternative. Upgrade to 3.2+ for the native implementation.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More