Course outline

Ruby / Contracts: callables, evaluation, failures, and value models / Lesson 11

Choose Data or Struct with explicit value semantics

Understand generated members, shallow value objects, with copies, eql?/hash contracts, and stable collection keys.

About 4 min readContent reviewed
On this page

Lesson goals

  • Distinguish Data’s assignment restrictions from deep immutability and validation.
  • Keep mutable state out of equality-defining hash keys.

Choose the record behavior the application actually needs

Struct creates a class with member readers and writers and several value-oriented operations. Data, introduced in Ruby 3.2 and included in this Ruby 3.3 baseline, defines value-like classes without ordinary member writers. Neither is a complete domain schema or authorization boundary. A Struct may allow omitted members to become nil depending on construction, while Data has its own required-member rules. Read constructor behavior rather than assuming the two are interchangeable. Choose a normal class when controlled state transitions or richer validation are central to the concept.

Create a changed value instead of assigning a member

The Data example keeps the original copy count while with constructs a second record with a changed count. Equality compares compatible value records according to their member semantics, not merely whether they share identity. The model does not validate positivity, identifier format, or ownership; those checks belong at a deliberate construction boundary. Using Data also requires a runtime that provides it, so the course does not claim compatibility with earlier Ruby versions. No Ruby executable was present, and the expected output was not measured or observed.

ruby A changed Data value and an equal independent value
RecordValue = Data.define(:id, :copies)
first = RecordValue.new(id: 'A-5', copies: 1)
changed = first.with(copies: 2)
puts first.copies
puts changed.copies
puts(first == RecordValue.new(id: 'A-5', copies: 1))
Expected output
1
2
true

Expected output is 1, 2, true. The original member is not reassigned. Data requires Ruby 3.2 or later; this source follows the selected 3.3 documentation and is unexecuted.

A value-like outer object can still hold mutable references

Data does not recursively freeze member objects. A contained array or string can remain shared and change even when the Data instance itself has no writer. with also follows the member references unless replacements are supplied. Decide whether a model promises a stable deep value, a shallow record, or shared live data. Mutable members can undermine cache keys, displayed snapshots, and assumptions about equality. An immutable-element model or explicit copy policy can be appropriate; a generic deep copy is not automatically correct for resources, cycles, or identities that the domain intends to share.

Test nested state instead of trusting the class name

The example mutates a supplied array after constructing a Data record. The change remains visible through the member reader, while respond_to? confirms that no ordinary labels= writer exists. Those observations answer different questions and should not be collapsed into a claim that Data is broken. Use synthetic nested values to verify the promised ownership level in an isolated project. Freezing only the outer record or renaming it Snapshot does not transform mutable element objects into stable values, and a passing equality check alone cannot prove isolation.

ruby A Data record with shared mutable contents
Labelled = Data.define(:labels)
labels = ['raw']
record = Labelled.new(labels: labels)
labels << 'extra'
puts record.labels.join('|')
puts record.respond_to?(:labels=)
Expected output
raw|extra
false

Expected output is raw|extra then false. Member assignment and member-object mutation are distinct. The script was not executed.

Hash keys need eql? and hash stability

Ruby Hash uses hash and eql? for key identity under its normal contract, which is distinct from every use of ==. A custom value key must keep these methods consistent, and equality-defining state should not change while stored. Matching hashes alone do not prove equal values because collisions exist. Do not persist hash output as a durable identifier or treat it as authorization. Check equal distinct records, unequal records, nested mutation, and lookup behavior in the actual selected runtime when available. This course establishes no executed equality, hashing, or concurrency result.

Common errors and fixes

A Data member’s array changes unexpectedly.

Why it happens
The outer value object retained a shared mutable reference.
How to fix it
Choose immutable members or a defined copy and ownership policy.
Prevent it next time
Test source and nested mutation after construction.

A Struct writer bypasses the required invariant.

Why it happens
Generated mutation was accepted without a model contract.
How to fix it
Use controlled methods or a suitable value model with validated construction.
Prevent it next time
Review all generated readers and writers as public API.

A custom hash key becomes unreliable after a field edit.

Why it happens
Its hash/eql? contract changed while it was stored.
How to fix it
Use stable value fields for keys or deliberate removal and reinsertion.
Prevent it next time
Review equality and mutation together before key use.

Check your understanding

1. Does calling with create a deep clone of a Data record?

Show worked solution

No. Unchanged members retain their references, so mutable nested objects can remain shared. Decide which levels must be independent and whether identity is meaningful. Replace or copy those members according to the contract rather than assuming the value-oriented outer API provides recursive immutability. Validate the resulting content separately.

2. Why is overriding only == insufficient for a custom Hash key?

Show worked solution

Hash’s normal key contract involves eql? and hash. A == override can leave those methods with identity-oriented or inconsistent behavior. Define the intended equivalence and compatible hash together, keep relevant state stable, and test lookup with equal independent instances. A pretty printed representation is unrelated to that requirement.

Key takeaways

  • Generated value operations do not replace domain validation or deep ownership.
  • Stable keys require consistent equality and hashing over stable state.

Official references