In Ruby, how do you give a Data.define class default values and validation so that new, [] and with all enforce them?
answer
- override initialize, not define
- positional args arrive as keywords
- call super with keywords last
- with reruns initialize
- frozen after super: no memoizing
basics
~20 sOverride 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 linesCoordinate = 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 FrozenErrorgo deeper
Recall that Data classes can override initialize and must call super, and that Data instances are frozen once constructed.
Explain that new and [] turn positional arguments into keywords before initialize runs, so the override declares keyword parameters with defaults.
Show that with reruns initialize on every copy, making initialize the single place invariants live, and name the FrozenError and missing-super traps.
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.