Ruby 3.2 introduces Data, a new core class for immutable value objects

rubySeptember 12, 2023Dotby Alkesh Ghorpade

Ruby 3.2 introduces Data, a new core class for immutable value objects. Value objects are a powerful tool for improving the quality of code. They are easy to understand and use and can help improve the readability and maintainability of code by making it more concise, consistent, and easier to reason about.

Value objects are immutable, which means that their state cannot be changed after they are created. This makes them thread-safe and easy to reason about.

How do we define and use Data class?

The Data class cannot be used directly, but it can be used as a base class for creating value objects.

To define a Data object, you need to use the Data.define method. This method takes a list of field names as its arguments. A Data class that represents a User can be defined as:

> User = Data.define(:first_name, :last_name, :email)
=> User

Data.define also accepts a block that can be used to define custom methods. You can create a custom method full_name for the above User class by defining a block.

class User < Data.define(:first_name, :last_name, :email)
  def full_name
    "#{first_name} #{last_name}"
  end
end

> user = User.new("Sam", "Example", "sam@example.com")
=> #<data User first_name="Sam", last_name="Example", email="sam@example.com">

> user.full_name
=> "Sam Example"

You can create a user instance by using the User class.

> User = Data.define(:first_name, :last_name, :email)
=> User

> user = User.new("Sam", "Example", "sam@example.com")
=> #<data User first_name="Sam", last_name="Example", email="sam@example.com">

> user.first_name
=> "Sam"

> user.email
=> "sam@example.com"

The values Sam, Example and sam@example.com got assigned to the respective attributes based on their positions. You can also initialize the user object by passing keyword arguments. But passing a combination of keyword and positional arguments will fail.

> user = User.new(first_name: "Sam", last_name: "Example", email: "sam@example.com")
=> #<data User first_name="Sam", last_name="Example", email="sam@example.com">

> user = User.new("Sam", last_name: "Example", email: "sam@example.com")
in `new': wrong number of arguments (given 2, expected 0) (ArgumentError)

If all the attributes are not set when initializing the user object, ArgumentError gets raised.

> user = User.new(first_name: "Sam", email: "sam@example.com")
=> `initialize': missing keyword: :last_name (ArgumentError)

OR

> user = User.new("Sam", "Example")
=> `initialize': missing keyword: :email (ArgumentError)

Updating the user attribute will raise an error as the User class is immutable.

> user.first_name = "Sam1"

undefined method `first_name=' for #<data User first_name="Sam", last_name="Example", email="sam@example.com"> (NoMethodError)

However, there is one exception. If the attributes of the Data class belong to a mutable class, immutability is not applied to these attribute values. For example, if you create a User data class with an address attribute of type JSON, the immutability will not be enforced on the values of the JSON keys.

> User = Data.define(:first_name, :last_name, :email, :address)
=> User

> user = User.new("Sam", "Example", "sam@example.com", { country: "US", pincode: "85001" })
#<data User first_name="Sam", last_name="Example", email="sam@example.com", address={:country=>"US", :pincode=>"85001"}>

> user.address[:pincode] = "85002"
=> "85002"

> user.address
=> {:country=>"US", :pincode=>"85002"}

The Data class provides some methods for working with Data objects. These methods include:

  • ==: This method compares two Data objects for equality.
  • to_h: This method converts a Data object to a hash.
  • deconstruct: This method returns an array of the values of a Data object.
  • members: This method returns an array of the field names of a Data object.
> User = Data.define(:first_name, :last_name, :email)

> user_a = User.new("Sam", "Example", "sam@example.com")
> user_b = User.new("Sam1", "Example1", "sam1@example.com")

> user_a == user_b
=> false

> user_a.to_h
=> {:first_name=>"Sam", :last_name=>"Example", :email=>"sam@example.com"}

> user_b.deconstruct
=> ["Sam1", "Example1", "sam1@example.com"]

> User.members
=> [:first_name, :last_name, :email]

OR

> user_a.members
=> [:first_name, :last_name, :email]

How Data class is different from Struct?

Structs are mutable, meaning their data can be changed, while Data is immutable, meaning its value cannot be changed.

> UserStruct = Struct.new(:first_name, :last_name, :email)
=> UserStruct

> user_struct = UserStruct.new("sam", "example", "sam@example.com")
=> #<struct UserStruct first_name="sam", last_name="example", email="sam@example.com">

> user_struct.first_name = "Sam1"
=> "Sam1"

> user_struct.first_name
=> "Sam1"

Unlike the Data class, Struct allows the creation of objects with missing arguments. This is where the Data class provides safety checks in comparison to Struct.

> user_struct = UserStruct.new("sam")
=> #<struct UserStruct first_name="sam", last_name=nil, email=nil>

The Data class is not intended to replace the Struct but can be used to store immutable atomic values. This makes it a convenient choice for Ruby developers who must create simple structures to store immutable data.

To know more about this feature, please refer to this PR.

Closing Remark

Could your team use some help with topics like this and others covered by ShakaCode's blog and open source? We specialize in optimizing Rails applications, especially those with advanced JavaScript frontends, like React. We can also help you optimize your CI processes with lower costs and faster, more reliable tests. Scraping web data and lowering infrastructure costs are two other areas of specialization. Feel free to reach out to ShakaCode's CEO, Justin Gordon, at justin@shakacode.com or schedule an appointment to discuss how ShakaCode can help your project!
Are you looking for a software development partner who can
develop modern, high-performance web apps and sites?
See what we've doneArrow right