skip to content

In Ruby, how do you give a Data.define class default values and validation so that new, [] and with all enforce them?

level: seniorimportance: nice to knowfreq 32%

answer

  1. override initialize, not define
  2. positional args arrive as keywords
  3. call super with keywords last
  4. with reruns initialize
  5. frozen after super: no memoizing

basics

~20 s

Override initialize with keyword parameters carrying defaults and checks, then call super with keywords. new and [] turn positional arguments into keywords first, and with reruns initialize for its copy, so every path hits the same checks.

solid answer

~40 s

`Data.define` takes only member names, so defaults and invariants live in an `initialize` override defined in its block. Data's `new` and `[]` map positional arguments to member keywords (rejecting more than there are members) and then call `initialize` with keywords only, so one signature such as `def initialize(lat:, lng:, altitude: 0)` serves both call styles. Normalise and validate there, then call `super(lat:, lng:, altitude:)`; `Data#initialize` stores the members, checks for missing or unknown keys and freezes the object. `with(**changes)` merges the changes into `to_h` and builds a fresh instance of the class, running `initialize` again, so the same checks apply to every copy; `with` with no arguments returns the receiver itself. After `super` the instance is frozen, so memoizing into an instance variable raises `FrozenError`.

code

ruby · 20 lines
ruby
Coordinate = Data.define(:lat, :lng, :altitude) do
  def initialize(lat:, lng:, altitude: 0)
    lat = Float(lat)
    lng = Float(lng)
    raise ArgumentError, "lat out of range: #{lat}" unless (-90.0..90.0).cover?(lat)
    raise ArgumentError, "lng out of range: #{lng}" unless (-180.0..180.0).cover?(lng)
    super(lat:, lng:, altitude:)
  end

  def label
    @label ||= format("%.4f,%.4f", lat, lng)  # FrozenError: instance is frozen
  end
end

here = Coordinate.new("52.52", 13.405)  # positional args reach initialize as keywords
here.altitude                           # => 0
Coordinate[lat: 91, lng: 0]             # ArgumentError: lat out of range: 91.0
here.with(lat: 95)                      # ArgumentError: lat out of range: 95.0
here.with.equal?(here)                  # => true
here.label                              # raises FrozenError

go deeper

for a junior

Recall that Data classes can override initialize and must call super, and that Data instances are frozen once constructed.

for a middle

Explain that new and [] turn positional arguments into keywords before initialize runs, so the override declares keyword parameters with defaults.

for a senior

Show that with reruns initialize on every copy, making initialize the single place invariants live, and name the FrozenError and missing-super traps.

for a principal

Decide how much validation a team puts in value objects versus at system boundaries, and keep constructors the only path to a valid instance.

## Where Data construction flows `Data.define(:lat, :lng, :altitude)` accepts only **member names**; there is no option for defaults or validation. What it does give you is a predictable construction path, and every hook you need sits on it: 1. `Coordinate.new(...)` or `Coordinate[...]` is called. 2. If the arguments are **positional**, `new` pairs them with the members in order and builds a keyword Hash. More positional arguments than members raise `ArgumentError` (wrong number of arguments) right here. 3. `new` allocates the object and calls **`initialize` with keywords only**. 4. `Data#initialize` stores each member, raises `ArgumentError` for missing keywords (`missing keyword: :lng`) or unknown ones, and **freezes** the instance. Because step 2 converts positional calls, an `initialize` override only ever sees keywords. That is the key fact: one keyword signature handles `Coordinate.new(52.5, 13.4)` and `Coordinate.new(lat: 52.5, lng: 13.4)` alike. ## Defaults and validation in initialize Define `initialize` in the block passed to `Data.define`. Give optional members a keyword default, normalise and check the values, then call `super` with keywords: ```ruby Coordinate = Data.define(:lat, :lng, :altitude) do def initialize(lat:, lng:, altitude: 0) lat = Float(lat) lng = Float(lng) raise ArgumentError, "lat out of range: #{lat}" unless (-90.0..90.0).cover?(lat) raise ArgumentError, "lng out of range: #{lng}" unless (-180.0..180.0).cover?(lng) super(lat:, lng:, altitude:) end end ``` - `altitude: 0` is the **default**: `Coordinate.new(52.52, 13.405)` passes only two positional values, which step 2 maps to `lat:` and `lng:`. - `Float(lat)` **normalises** input such as the String `"52.52"` and raises on garbage. - The range checks are the **invariants**; they raise before `super`, so no half-valid object escapes. - `super(lat:, lng:, altitude:)` uses the Ruby 3.1 keyword shorthand to hand the cleaned values to `Data#initialize`. ## with runs the same checks `Data#with(**changes)` does not poke new values into a copy. It takes `to_h`, merges the changes, **allocates a new instance of the same class and calls `initialize`** with the merged keywords, so your override runs again. (It does not go through a Ruby-level `self.new` override.) | Call | Goes through your `initialize`? | |---|---| | `Coordinate.new(52.5, 13.4)` | yes, as keywords | | `Coordinate[lat: 52.5, lng: 13.4]` | yes | | `here.with(lat: 95)` | yes, so it raises `lat out of range: 95.0` | | `here.with` (no arguments) | no, returns `here` itself | | `here.with(alt: 5)` | yes, and your keyword signature rejects the unknown key | That makes `initialize` the single choke point for invariants, which is exactly what a value object needs. ## Frozen after super `Data#initialize` freezes the instance. Consequences: - Setting an extra instance variable **after** `super`, or memoizing with `@label ||= ...` in a later method call, raises **`FrozenError`**. - Derived values should be computed in methods each time, or made into members. - If an override **forgets to call `super`**, the members stay `nil` and the object is **not frozen**, silently breaking the value-object promise. Treat a missing `super` as a bug. ## Alternative constructors Parsing belongs in a class method that ends in `new`, so it cannot bypass the checks: ```ruby def self.parse(text) = new(*text.split(",")) ``` Defined inside the `Data.define` block, `Coordinate.parse("52.52,13.405")` reaches the same `initialize`. ## Testing the invariants Because every construction path funnels into `initialize`, a small set of tests covers them all: - a valid positional call and a valid keyword call produce equal values; - an out-of-range latitude raises `ArgumentError` from `new`; - the same out-of-range value raises from `with`, proving copies cannot escape the rule; - the result is `frozen?`, which catches an override that forgot `super`. ## Pitfalls to avoid - Declaring **positional** parameters in the override (`def initialize(lat, lng)`): `new` always passes keywords, so calls fail with `ArgumentError`. - Validating in a custom `self.new` instead of `initialize`: `with` allocates and calls `initialize` directly, so a check that lives only in `self.new` is skipped for every `with` copy. - Subclassing the generated class (`class Coordinate < Data.define(...)`) just to add `initialize`; the block, or reopening `class Coordinate` after the constant is assigned, avoids the extra ancestor that `Style/DataInheritance` flags.

  • What happens if a Data initialize override never calls super?
    The members are never stored, so every reader returns nil, and the object is never frozen because freezing happens inside Data#initialize. No error is raised, so the value object quietly loses both its data and its immutability. Always end the override with a keyword super call.
  • Why does an override declared as `def initialize(lat, lng)` break `Coordinate.new(1, 2)`?
    Data's new converts the positional arguments into a keyword Hash before calling initialize, and calls initialize with keywords. A method that declares no keyword parameters receives them as one positional Hash, so `initialize(lat, lng)` gets one argument where it needs two and raises ArgumentError. Overrides must declare keyword parameters.

saying these in an interview costs you the question

  • Positional arguments reach an overridden Data initialize as positional parameters.
  • Data#with copies the instance without running initialize, so validation is skipped.
  • Memoizing a derived value in an instance variable works on a Data instance.
  • An initialize override that skips super still yields a frozen, fully set instance.
  • Default values belong in the Data.define call as keyword options.