In Ruby 3.2, a new class Data was introduced as a way to define simple immutable value objects. A value object is a type of object that represents a value in a program, such as a point in 2D space or a date. The main advantage of value objects is that they are easy to understand, simple to use, and can improve the readability and maintainability of code. The proposal to add Data class was accepted by Matz on the Ruby forum here.

How does it work?

Using the newly defined class Data we can create a simple immutable object. These objects are designed to be small, self-contained, and represent a single concept in your application.
The class definition includes the name of the class, as well as a list of instance variables that the object will contain. Here's an example:

class Point < Data.define(:x, :y)
end

# Can be initialized using Positional or Keyword arguments
point = Point.new(3, 4) OR Point.new(x: 3, y: 4) OR Point[3, 4] OR Point[x: 3, y: 4]
=> #<data Point x=3, y=4>

# But using both Positional and Keyword arguments together will not work
Point.new(2, y: 3)
=> `new`: wrong number of arguments (given 2, expected 0) (ArgumentError)

Once an instance of the object is created, its instance variables cannot be changed. This makes it easy to understand about the object's state and prevents bugs that can arise from unexpected changes to the object.

However, if we need to change any one instance variable by keeping the other variable same we can do that using with method.

point2 = point.with(x: 10)
=> #<data Point x=10, y=4>

Since Data objects are immutable, with method creates a copy of the object to update the arguments.
Note: If the with method is called with no arguments, the receiver is returned as-is and no new copy is created.

Data.define also accepts an optional block that can be used to define custom methods for the immutable object. These methods can provide additional functionality to the object without compromising its immutability.

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

point = Point.new(3, 4)
=> #<data Point x=3, y=4>

point.distance_from_origin
=> 5.0

Why not Struct?

While Struct can also be used to define objects, there are some reasons why Data might be a better choice in certain situations.

First, Data provides some safety checks that Struct does not. For example, Data prevents an object from being created with a missing argument, while Struct allows this. This can help to prevent bugs and improve code safety.

Here's an example:

Measure = Struct.new(:amount, :unit)

measure = Measure.new(30) # This works, but will fail with Data
=> #<struct Measure amount=30, unit=nil>
Point.new(x: 3) # Missing argument will raise error for Data
=> `initialize': missing keyword: :y (ArgumentError)

In this example, we've defined a Measure struct that contains two instance variables: amount and unit. However, when we create a new instance of the object, we only provide one argument. This is allowed by Struct, but it can lead to bugs if the code assumes that all the instance variables are present.

Second, Data is a more explicit way of defining simple immutable objects. The Data class makes it clear that the object is intended to be immutable, while with Struct it's less clear.

Final Thoughts

Data is a powerful tool for defining simple immutable objects in Ruby 3.2. While it may not be suitable for all situations, its safety, performance, and clarity benefits make it a strong choice in many cases. By understanding how Data works and its tradeoffs compared to other techniques, such as Struct, you can make an informed decision about when to use it in your own code.

References

  1. Add Data class implementation: Simple immutable value object PR#6353
  2. Add copy with changes functionality for Data objects PR#6766
  3. https://docs.ruby-lang.org/en/3.2/Data.html
  4. https://bugs.ruby-lang.org/issues/16122