rubyguides

Ruby 3.2 Data Class: Immutable Value Objects with Pattern Matching

Data is Ruby’s built-in way to define simple classes for immutable value objects. Added in Ruby 3.2, it’s the pragmatic choice when you want something like a Struct but with immutability enforced by default — no setters, no accidental mutation after creation.

TL;DR

Use Data when you want a tiny immutable object whose shape is obvious at the call site. It works well for points, records, and other value objects where equality should come from the fields, not from object identity. If you need defaults, mutation, or iteration, Struct is usually the better fit.

Defining a data class

Point = Data.define(:x, :y)
origin = Point.new(x: 0, y: 0)
origin.x  # => 0

Both positional and keyword arguments work at construction time:

Point.new(1, 2)          # positional — args are mapped to members in order
Point.new(x: 1, y: 2)    # keyword — same result

That flexibility is helpful when you are migrating older code or when a small object crosses a few different layers of the app. The important part is that the fields stay explicit. You can still see which values are being assigned, and the constructor stays small enough to read at a glance.

Immutability

Data instances are frozen immediately on creation. There are no setter methods, so any attempt to assign a member raises NoMethodError:

p = Point.new(x: 1, y: 2)
p.x = 3  # NoMethodError: undefined method `x=' for Point

This makes Data a good fit for value objects where identity is based on content, not mutability. Immutability is the main reason many Ruby developers reach for Data first. A value object is usually easier to reason about when it cannot change after creation. That makes it safer to pass between methods, easier to compare in tests, and less likely to drift away from the state you thought you created.

Mandatory members

All members defined with Data.define are required. There is no built-in way to provide defaults, so omitting any member raises ArgumentError at construction time:

Config = Data.define(:host, :port)

Config.new(host: "localhost")
# ArgumentError: missing keyword: :port

If you need defaults, Struct is a better fit, or you can provide your own initialize that fills in missing values before calling super:

Config = Data.define(:host, :port) do
  def initialize(host:, port: 3000)
    super(host: host, port: port)
  end
end

This limitation is intentional. Data keeps the object definition minimal, which is part of what makes it easy to scan. If the class starts needing fallback values, the object is no longer quite as simple, and a regular class may give you a clearer place to express those rules.

Accessing members

Member access uses standard Ruby reader methods, one per member. Data also provides to_h for converting to a hash and members for inspecting the field names at the class level:

Color = Data.define(:red, :green, :blue)

c = Color.new(red: 255, green: 128, blue: 64)
c.red        # => 255
c.green      # => 128
c.to_h       # => {:red=>255, :green=>128, :blue=>64}
Color.members  # => [:red, :green, :blue]

Equality and hashing

Two Data instances with the same class and member values are equal, and they hash identically. This makes them natural Hash keys, which is useful for caching, memoization, or lookup tables keyed by value objects:

a = Point.new(x: 1, y: 2)
b = Point.new(x: 1, y: 2)

a == b  # => true
a.hash == b.hash  # => true — can use as Hash keys

h = { a => "origin point" }
h[b]  # => "origin point"

Pattern matching

Data pairs particularly well with Ruby’s pattern matching syntax. Each Data class implements deconstruct for array-style matching and deconstruct_keys for hash-style matching. This means you can destructure a Data object directly inside a case/in expression without writing any unpacking code yourself. The pattern matching engine calls the right deconstruct method based on the pattern shape you provide:

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

case Point.new(x: 10, y: 20)
in [Integer, Integer]
  puts "Matched as array: #{$1}, #{$2}"
in x:, y:
  puts "Matched as hash pattern: x=#{x}, y=#{y}"
end

This makes it easy to destructure Data objects in case/in expressions, especially when working with collections of value objects.

That is one of the places where Data feels closest to the shape of the problem. A value object with named fields maps neatly to a match expression, so the code can ask for the structure it expects without pulling the object apart by hand. The result is less bookkeeping and more attention on the branch conditions that actually matter.

Pattern matching is where Data starts to feel especially natural. A small immutable object with named fields is exactly the sort of thing that can move through a case expression without extra boilerplate. Instead of unpacking values manually, you can match the shape you expect and keep the control flow focused on the data itself.

When you are using Data in real code, think about whether the object is mostly a snapshot. That is often the case for geometry, configuration, parsed input, or tiny domain records. Those objects usually want to be read more than they want to be changed, which is exactly where immutability helps. A Data class gives you that shape without forcing a larger class definition.

When data falls short

Mutable members are not protected. If a member holds a reference to a mutable object, that object can still be changed:

Record = Data.define(:values)
r = Record.new(values: [1, 2, 3])
r.values << 4  # Allowed — the Array inside is mutable

Data freezes the container, not its contents. If you need deep immutability, freeze members manually before assignment.

That distinction matters whenever a member points to a mutable object like an array or hash. Data protects the outer object, but it does not rewrite the rules for the values inside it. If deep immutability matters, freeze the nested object before you pass it in, or copy it into a shape that cannot change.

Not Enumerable. Data does not include Enumerable or provide each. Use Struct if you need to iterate over members.

No inheritance hierarchy. Data.define creates a simple leaf class. For shared behavior across multiple Data classes, Struct or a regular class is more flexible.

Data vs Struct

DataStruct
Frozen by defaultYesNo
Mandatory membersYesNo
Setter methodsNoneYes
Pattern matching helpersYesNo
Enumerable supportNoYes

The rule of thumb: use Data for plain value objects where immutability is desirable, and Struct when you need mutability, defaults, or iteration.

Another useful way to think about the choice is by lifetime. If the object is created, passed along, and read more often than it is changed, Data is usually a good fit. If the object needs to absorb updates over time, then the added flexibility of Struct or a regular class is easier to live with. The key is not to force immutability where the workflow clearly wants mutation.

For small applications and libraries, Data often pays off because it keeps the class definition short and the intent clear. That makes it a pleasant default for records, positions, settings snapshots, and other objects whose value comes from the fields they hold. When you want that shape, Ruby 3.2’s Data class gives you a tidy starting point.

In practice, the choice is usually about how much behavior you want to hang on the class. If the object is mostly a bundle of named values, Data keeps the definition short and the intent obvious. If the object needs to grow helper methods, optional values, or traversal behavior, Struct or a regular class will usually give you a better fit.

In other words, the data class in Ruby is a good fit when the shape of the value matters more than the behavior attached to it. If you can describe the object in one sentence and that sentence sounds like a record, Data is worth reaching for. If you need a little more ceremony, that is a sign to step up to Struct or a normal class before the object becomes awkward to evolve.

See Also