Ruby's New Data Class (Ruby 3.2+): The Feature You Didn't Know You Needed

Ruby's New Data Class (Ruby 3.2+): The Feature You Didn't Know You Needed

Ruby 3.2, released in December 2022, introduced the Data class. A built-in, immutable value object for Rubyists who want simple, safe, and fast data structures without the boilerplate of custom classes or Structs. If you haven’t upgraded to Ruby 3.2 or later, you’re missing out on this powerful feature!

The Purpose of Ruby’s Data Class

The purpose of the Data class is to store immutable, atomic values. Think of it as a safer, simpler way to represent data that shouldn’t change after creation. It’s ideal for representing simple data, configuration, or value objects.

Why Ruby Needed a Data Class: The Value Object Pattern

The motivation for Ruby’s Data class comes from the need for simple, immutable value objects, an idea popularized by Martin Fowler and widely used in many programming languages. Value objects are small objects whose equality is based on their values, not their identity. They are ideal for representing things like coordinates, money, or ranges, and are a core concept in Domain-Driven Design.

Before Data, Rubyists often used Struct for this purpose, but Structs are mutable and behave like collections, which can lead to subtle bugs and confusion. The Data class was introduced in Ruby 3.2 to provide a safer, clearer alternative, inspired by the value object pattern described by Fowler and others.

For more background, see:

Basic Example Usage

Person = Data.define(:name, :age)
p = Person.new("Julio", 30)
p.name # => "Julio"
p.age  # => 30
p[:name] # => "Julio"
p.to_h   # => { name: "Julio", age: 30 }

Optional block

You can pass an optional block to Data.define to add custom methods:

Point = Data.define(:x, :y) do
  def distance
    Math.sqrt(x**2 + y**2)
  end
end
p = Point.new(3, 4)
p.distance # => 5.0

Instantiating Data Objects

You can create instances using .new, [], keyword, or positional arguments:

Person = Data.define(:name, :age)
Person.new("Julio", 30)
Person["Julio", 30]
Person.new(name: "Julio", age: 30)
Person[name: "Julio", age: 30]

Immutability and #frozen?

All Data objects are frozen by default:

p = Person.new("Julio", 30)
p.frozen? # => true

Comparison with Struct

Data objects have far fewer instance methods than Structs, reducing the risk of method conflicts and making them easier to reason about. You get only the essentials: ==, [], to_h, etc. For the complete list of available methods, see the official Ruby Data documentation opens a new window .

Unlike Struct, which requires manual copying or mutation, Data provides a unique #with method for creating a copy with updated fields (without mutation):

p2 = p.with(age: 31)
p2.age # => 31
p.age # => 30

Before Ruby 3.2, most Rubyists used Struct for simple value objects. But Structs are mutable and can be accidentally changed, leading to hidden bugs.

Old Way: Using Struct

Person = Struct.new(:name, :age)
p = Person.new("Julio", 30)
p.name = "Ana" # Oops! This changes the name
p.age += 1     # Oops! This changes the age

Structs are convenient, but their mutability can cause problems when you expect your data to remain unchanged.

New Way: Using Data

Person = Data.define(:name, :age)
p = Person.new("Julio", 30)
p.name = "Ana" # => NoMethodError: undefined method `name=' for #<data Person>
p.age += 1     # => NoMethodError: undefined method `age=' for #<data Person>

With Data, your objects are truly immutable. You can’t accidentally change their fields, making your code safer and easier to reason about.

Migration Tips

  • Replace simple Structs with Data for better safety.
  • Use Data for configuration, Data Transfer Objects (DTOs), or anywhere you want immutable value objects.
  • Data is available in Ruby 3.2+, so check your Ruby version before migrating.

Real Life Scenarios Where Data Shines

Here are some practical situations where using Data instead of Struct makes a real difference:

API Response Object

ApiResponse = Data.define(:status, :body)
response = ApiResponse.new(200, "{...}")
# response.status and response.body can't be changed

Configuration Settings

Config = Data.define(:host, :port)
config = Config.new("localhost", 3000)
# config.host and config.port are immutable

Domain Event

UserRegistered = Data.define(:user_id, :timestamp)
event = UserRegistered.new(42, Time.now)
# event.user_id and event.timestamp are fixed

Background Job Arguments

JobArgs = Data.define(:user_id, :action)
args = JobArgs.new(7, "send_email")
# Safe to pass args to Sidekiq or other job processors

These scenarios show how Data helps you write safer, more reliable Ruby code in real-world projects.

Final Thoughts

Ruby’s Data class is a welcome addition for anyone who wants to write safer, cleaner, and more maintainable code. By making value objects truly immutable, it helps prevent subtle bugs and makes your intent clear. Whether you’re building APIs, handling configuration, or passing data between services, using Data can simplify your code and improve reliability.

If you haven’t tried it yet, consider refactoring some of your Structs or custom classes to use Data. You’ll likely find your code easier to test, reason about, and maintain. As Ruby continues to evolve, features like Data show a commitment to modern programming practices and developer happiness. Give it a try in your next project and see the difference for yourself!

If you don’t have the time to do it yourself you can hire our team to do it for you, We can help! opens a new window

References

  1. Ruby 3.2 Release Notes: https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/ opens a new window
  2. Official Ruby Data Documentation (Ruby 3.4): https://docs.ruby-lang.org/en/3.4/Data.html opens a new window
  3. Ruby Data Class Feature Request (Ruby Issue #16122): https://bugs.ruby-lang.org/issues/16122 opens a new window
Get the book