Wholeable allows you to turn your object into a whole value object by ensuring object equality is determined by the values of the object instead of object identity. Whole value objects — or value objects in general — have the following traits:
-
Equality is determined by the values that make up an object and not by identity (i.e. memory address) which is the default behavior for all Ruby objects except for Data and Structs.
-
Identity remains unique since two objects can have the same values but different identity. This means
BasicObject#equal?is never overwritten — which is strongly discouraged — as per BasicObject documentation. -
Value objects should be immutable (i.e. frozen) by default. This implementation enforces a strict adherence to immutability in order to ensure value objects remain equal and discourage mutation.
-
Ensures equality (i.e.
#==and#eql?) is determined by attribute values and not object identity (i.e.#equal?). -
Allows you to compare two objects of same or different types and see their differences.
-
Provides pattern matching.
-
Provides inheritance so you can subclass and add attributes or provide additional behavior.
-
Automatically defines public attribute readers (i.e.
.attr_reader) if immutable (default) or public attribute readers and writers (i.e..attr_accessor) if mutable. -
Ensures object inspection (i.e.
#inspect) shows all registered attributes. -
Ensures object is frozen upon initialization by default.
-
Ruby.
To install with security, run:
# 💡 Skip this line if you already have the public certificate installed.
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
gem install wholeable --trust-policy HighSecurityTo install without security, run:
gem install wholeableYou can also add the gem directly to your project:
bundle add wholeableOnce the gem is installed, you only need to require it:
require "wholeable"To use, include Wholeable along with a list of attributes that make up your whole value object:
require "wholeable"
class Person
include Wholeable[:name, :email]
def initialize name:, email:
@name = name
@email = email
end
end
jill = Person[name: "Jill Smith", email: "jill@example.com"]
jill_two = Person[name: "Jill Smith", email: "jill@example.com"]
jack = Person[name: "Jack Smith", email: "jack@example.com"]
Person.members # [:name, :email]
jill.members # [:name, :email]
jill.name # "Jill Smith"
jill.email # "jill@example.com"
jill.frozen? # true
jill_two.frozen? # true
jack.frozen? # true
jill.inspect # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
jill_two.inspect # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
jack.inspect # "#<Person @name=\"Jack Smith\", @email=\"jack@example.com\">"
jill == jill # true
jill == jill_two # true
jill == jack # false
jill.diff(jill) # {}
jill.diff(jack) # {
# name: ["Jill Smith", "Jack Smith"],
# email: ["jill@example.com", "jack@example.com"]
# }
jill.diff(Object.new) # {:name=>["Jill Smith", nil], :email=>["jill@example.com", nil]}
jill.eql? jill # true
jill.eql? jill_two # true
jill.eql? jack # false
jill.equal? jill # true
jill.equal? jill_two # false
jill.equal? jack # false
jill.hash # 3650965837788801745
jill_two.hash # 3650965837788801745
jack.hash # 4460658980509842640
jill.to_a # ["Jill Smith", "jill@example.com"]
jack.to_a # ["Jack Smith", "jack@example.com"]
jill.to_h # {:name=>"Jill Smith", :email=>"jill@example.com"}
jack.to_h # {:name=>"Jack Smith", :email=>"jack@example.com"}
jill.to_s # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
jill_two.to_s # "#<Person @name=\"Jill Smith\", @email=\"jill@example.com\">"
jack.to_s # "#<Person @name=\"Jack Smith\", @email=\"jack@example.com\">"
jill.with name: "Sue" # #<Person @name="Sue", @email="jill@example.com">
jill.with bad: "!" # unknown keyword: :bad (ArgumentError)As you can see, object equality is determined by the object’s values and not by the object’s identity. When you include Wholeable along with a list of keys, the following happens:
-
The corresponding public
attr_reader(orattr_accessorif mutable) for each key is created which saves you time and reduces double entry when implementing your whole value object. -
The
#to_a,#to_h, and#to_smethods are added for convenience and to be compatible with Data and Structs. -
The
#deconstructand#deconstruct_keysaliases are created so you can leverage pattern matching. -
The
#==,#eql?,#hash,#inspect, and#withmethods are added to provide whole value behavior. -
The object is immediately frozen after initialization to ensure your instance is immutable by default.
As shown above, you can create an instance of your whole value object by using .[]. Example:
Person[name: "Jill Smith", email: "jill@example.com"]Alternatively, you can create new instances using .new. Example:
Person.new name: "Jill Smith", email: "jill@example.com"Both methods work but use .[] when supplying arguments and .new when you don’t have any arguments.
Instances are frozen by default. You can change behavior by specifying whether instances should be mutable by passing kind: :mutable as a keyword argument. Example:
require "wholeable"
class Person
include Wholeable[:name, :email, kind: :mutable]
def initialize name: "Jill", email: "jill@example.com"
@name = name
@email = email
end
end
jill = Person.new
jill.frozen? # falseWhen your object is mutable, you’ll also have access to setter methods in addition to the normal getter methods. Example:
jill.name # "Jill"
jill.name = "Jayne"
jill.name # "Jayne"You can also make your object immutable by using kind: :immutable but this is default behavior and redundant. Any invalid kind (example: kind: :bogus) will be ignored and default to being immutable.
require "wholeable"
class Person
include Wholeable[:name]
def initialize name:
@name = name
end
end
class Contact < Person
include Wholeable[:email]
def initialize(email:, **)
super(**)
@email = email
end
end
contact = Contact[name: "Jill Smith", email: "jill@example.com"]
contact.to_h # {name: "Jill Smith", email: "jill@example.com"}
contact.frozen? # trueNotice Contact inherits from Person while only defining the attributes that make it unique. You don’t need to redefine the same attributes found in the superclass as that would be redundant and defeat the purpose of subclassing in the first place.
When subclassing, each subclass has access to the same attributes defined by the superclass no matter how deep your ancestry is. This does mean you must pass the remaining attributes to the superclass via the double splat.
Mutability is honored but is specific to each object in the ancestry. In other words, if the entire ancestry is immutable then no object can mutate an attribute defined in the ancestry. The same applies if the entire ancestry is mutable except, now, any child can mutate any attribute previously defined by the ancestry. Any attribute that is mutated is only mutated specific to the subclass as is standard inheritance behavior.
If your ancestry is a mixed (immutable and mutable) then behavior is specific to each child in the ancestry. This means a mutable child won’t make the entire ancestry mutable, only the child will be mutable. Best practice is to architect your ancestry so immutability or mutability is the same across all objects. To illustrate, here’s an example with an immutable parent and mutable child:
require "wholeable"
class Parent
include Wholeable[:one]
def initialize one: 1
@one = one
end
end
class Child < Parent
include Wholeable[:two, kind: :mutable]
def initialize(two: 2, **)
super(**)
@two = two
end
end
child = Child.new
child.one = 100 # NoMethodError
child.two = 200 # 200
child.frozen? # falseNotice, when attempting to mutate the one attribute, you get a NoMethodError. This is because #one= is defined by the immutable parent while #two= is defined on the mutable child.
If you the flip mutability of your ancestry, you can make your parent mutable while the child is immutable with different behavior. Example:
require "wholeable"
class Parent
include Wholeable[:one, kind: :mutable]
def initialize one: 1
@one = one
end
end
class Child < Parent
include Wholeable[:two]
def initialize(two: 2, **)
super(**)
@two = two
end
end
child = Child.new
child.one = 100 # FrozenError
child.two = 200 # NoMethodError
child.frozen? # trueIn this case, you get a FrozenError for #one= because the parent is mutable and defined the #one= method but the child is immutable which caused the associated attribute to be frozen. On the other hand, the #two= method is never defined by the subclass due to being immutable and so you you get a: NoMethodError.
Again, if using inheritance, ensure immutability or mutability remains consistent throughout the entire ancestry.
Whole values can be broken via the following situations:
-
Post Attributes: Adding additional attributes after what is defined when including
Wholeablewill break your whole value object. To prevent this, let Wholeable manage this for you (easiest). Otherwise (harder), you can manually override#==,#eql?,#hash,#inspect,#to_a, and#to_hbehavior at which point you don’t need Wholeable anymore. -
Deep Freezing: The automatic freezing of your instances is shallow and will not deep freeze nested attributes. This behavior mimics the behavior of Data objects.
The performance of this gem is slightly slower than native support for Data and Structs because they are written in C. To illustrate, here’s a micro benchmark for comparison:
INITIALIZATION
ruby 4.0.1 (2026-01-13 revision e04267a14b) +YJIT +PRISM [arm64-darwin25.2.0]
Warming up --------------------------------------
Data 411.580k i/100ms
Equalizer 2.225M i/100ms
Equatable 2.321M i/100ms
Struct 455.823k i/100ms
Wholeable 859.120k i/100ms
Calculating -------------------------------------
Data 4.903M (± 8.8%) i/s (203.94 ns/i) - 24.695M in 5.072786s
Equalizer 25.434M (± 8.5%) i/s (39.32 ns/i) - 126.805M in 5.018058s
Equatable 25.399M (± 7.9%) i/s (39.37 ns/i) - 127.656M in 5.053633s
Struct 4.811M (± 7.7%) i/s (207.85 ns/i) - 24.159M in 5.048700s
Wholeable 10.649M (± 8.9%) i/s (93.91 ns/i) - 53.265M in 5.044738s
Comparison:
Equalizer: 25434240.3 i/s
Equatable: 25398808.9 i/s - same-ish: difference falls within error
Wholeable: 10648737.2 i/s - 2.39x slower
Data: 4903352.0 i/s - 5.19x slower
Struct: 4811046.3 i/s - 5.29x slower
MESSAGING
ruby 4.0.1 (2026-01-13 revision e04267a14b) +YJIT +PRISM [arm64-darwin25.2.0]
Warming up --------------------------------------
Data 125.714k i/100ms
Equalizer 97.991k i/100ms
Equatable 94.801k i/100ms
Struct 121.077k i/100ms
Wholeable 96.806k i/100ms
Calculating -------------------------------------
Data 1.368M (±12.1%) i/s (731.11 ns/i) - 6.789M in 5.052046s
Equalizer 997.443k (±11.4%) i/s (1.00 μs/i) - 4.998M in 5.074622s
Equatable 996.404k (± 9.6%) i/s (1.00 μs/i) - 5.024M in 5.087767s
Struct 1.429M (±10.0%) i/s (699.99 ns/i) - 7.144M in 5.051871s
Wholeable 967.377k (±10.7%) i/s (1.03 μs/i) - 4.840M in 5.060414s
Comparison:
Struct: 1428586.9 i/s
Data: 1367792.5 i/s - same-ish: difference falls within error
Equalizer: 997443.1 i/s - 1.43x slower
Equatable: 996404.3 i/s - 1.43x slower
Wholeable: 967377.2 i/s - 1.48x slower
While the above isn’t bad, you can see this gem is slower than Ruby’s own native objects during interaction despite being faster upon initialization.
To contribute, run:
git clone https://github.com/bkuhlmann/wholeable
cd wholeable
bin/setupYou can also use the IRB console for direct access to all objects:
bin/console-
Built with Gemsmith.
-
Engineered by Brooke Kuhlmann.