field_struct 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. metadata +139 -0
data/USAGE.md ADDED
@@ -0,0 +1,820 @@
1
+ # FieldStruct — usage reference
2
+
3
+ Dense, example-first reference for using the `field_struct` gem. Optimized for an
4
+ agent (or human) to load once and use the whole API correctly. For narrative
5
+ docs see `README.md`; for design rationale see `docs/origin/plan.md`.
6
+
7
+ **Mental model:** declare fields on a `FieldStruct::Base` subclass. Each field
8
+ gets a coerced, typed reader/writer and presence/format validation. Construction
9
+ and validity are *separate* — you can build an invalid instance; ask `valid?`.
10
+
11
+ <!-- doctest -->
12
+ ```ruby
13
+ class User < FieldStruct::Base
14
+ required :name, :string
15
+ optional :age, :integer
16
+ end
17
+
18
+ u = User.new(name: 'Alice', age: '30') # string + symbol keys both work
19
+ u.name # => "Alice"
20
+ u.age # => 30
21
+ u.attributes # => { name: "Alice", age: 30 }
22
+ u.valid? # => true
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Types
28
+
29
+ Register name in the first column; use it as the second arg to `field`/`required`/
30
+ `optional`. "Coerces from" is what the setter accepts (`nil` always passes through
31
+ as `nil`). "Missing" is what counts as absent for presence checks.
32
+
33
+ | Name | Ruby type | Coerces from | Type options | Missing when |
34
+ |------|-----------|--------------|--------------|--------------|
35
+ | `:string` | `String` | `value.to_s` (`strict: true`: scalars and `to_str` only) | `format:`, `enum:`, `strict:` | nil / empty / whitespace-only |
36
+ | `:immutable_string` | `String` (frozen) | `to_s`, then frozen dup | `format:`, `enum:`, `strict:` | nil / empty / whitespace |
37
+ | `:integer` | `Integer` | strings in base 10 (`"010"` → 10; `"0x1A"`/`"3.14"` raise), or `base:`; floats truncate (`strict: true`: refused unless whole) | `in:`, `base:`, `strict:` | nil |
38
+ | `:float` | `Float` | `Kernel.Float` (strict; base-10 strings only — `"0x1A"` raises; NaN/±Infinity refused) | `round:`, `in:` | nil |
39
+ | `:big_decimal` (alias `:decimal`) | `BigDecimal` | BigDecimal/Integer/Float/numeric string (NaN/±Infinity refused) | `round:`, `in:` | nil |
40
+ | `:boolean` | `true`/`false` | `true`/`false`, `1`/`0`, truthy/falsy strings | `values:` | nil |
41
+ | `:date` | `Date` | `Date`, `DateTime`/`Time` (narrowed), String | `format:`, `in:` | nil |
42
+ | `:time` | `Time` | `Time`, `Date`/`DateTime` (from components), String | `format:`, `in:` | nil |
43
+ | `:datetime` | `DateTime` | `DateTime`, `Date`/`Time` (from components), String | `format:`, `in:` | nil |
44
+ | `:symbol` | `Symbol` | `Symbol`, `String#to_sym` (else `TypeError`) | `enum:` | nil |
45
+ | `:uuid` | `String` | `to_s` | `format:` (`:any_version`/`:v4`/`:v7`), `enum:`, `strict:` | nil / empty / whitespace |
46
+ | `:url` | `String` | `to_s` | `format:` (`:http`/`:https_only`/`:any_scheme`), `enum:`, `strict:` | nil / empty / whitespace |
47
+ | `:email` | `String` | `to_s` | `format:` (`:permissive`/`:default`/`:strict`), `enum:`, `strict:` | nil / empty / whitespace |
48
+ | `:binary` | `String` (ASCII-8BIT) | `to_s`, forced to binary encoding | `strict:` | nil / empty (**whitespace bytes are kept**) |
49
+ | `:value` | `Object` (untyped) | pass-through, no coercion | — | nil |
50
+ | `:array` | `Array` | each element coerced through `of:` (with `element:` options) | `of:` (**required**), `element:` | nil / empty |
51
+ | `:union` | first matching member | tries each member type | `of:` (**Array, ≥2**) | nil |
52
+ | nested (a `FieldStruct::Base` subclass) | that class | Hash → instance, or instance | — | nil |
53
+
54
+ ```ruby
55
+ required :id, :uuid, format: :v4
56
+ required :website, :url, format: :https_only
57
+ optional :email, :email # default regex
58
+ required :status, :symbol, enum: %i[active archived]
59
+ required :score, :integer, in: 0..100
60
+ required :price, :big_decimal, round: 2
61
+ required :code, :integer, base: 16 # "1A" → 26
62
+ optional :tags, :array, of: :string
63
+ optional :ids, :array, of: :integer, element: { strict: true }
64
+ required :ref, :union, of: %i[string integer]
65
+ optional :address, Address # nested FieldStruct
66
+ optional :lines, :array, of: LineItem # array of nested
67
+ ```
68
+
69
+ **Each type describes its own options at runtime** — you don't have to trust this
70
+ table. `FieldStruct.types.lookup(:date).option_schema` returns
71
+ `{ format: {type:, required:, presets:}, in: {...} }`. The DSL validates these
72
+ *native* options at class load: a wrong-shaped value (`round: '2'`) or a
73
+ misapplied option (`enum:` on `:integer`) raises with the expected shape / the
74
+ types it applies to. An option the type doesn't recognize is **not** an error —
75
+ it's stored verbatim on the `Field`, so downstream tooling (e.g. an Avro exporter)
76
+ can stash and read its own options.
77
+
78
+ ---
79
+
80
+ ## Field options (any type)
81
+
82
+ | Option | Meaning |
83
+ |--------|---------|
84
+ | `required: true` | presence-checked (`required :x` is sugar; `optional :x` / bare `field :x` are not) |
85
+ | `default:` | literal, **or** a callable (`-> { Time.now }`, a Proc/Method) invoked once per instance |
86
+ | `coercion_policy:` | `:keep_raw` (default) \| `:replace` \| `:raise` — per-field override of the class setting |
87
+ | `description:` / `desc:` | human text; introspection only — **never** in `attributes`/`as_json`/pattern match |
88
+ | `rbs_type:` | String of RBS that `FieldStruct::RBS` emits for this field instead of the derived type (`'::ActiveRecord::Relation'`); signatures only, no runtime effect |
89
+ | `sensitive:` | `true` renders `[FILTERED]` for the value in `inspect` / `pp`; data paths unchanged — see [Log safety](#log-safety-error-messages-and-sensitive-fields) |
90
+
91
+ ```ruby
92
+ class Account < FieldStruct::Base
93
+ required :id, :uuid, default: -> { SecureRandom.uuid }, desc: 'primary key'
94
+ optional :balance, :big_decimal, default: 0, coercion_policy: :raise
95
+ end
96
+ ```
97
+
98
+ Type-specific options: `format:` (string-shaped + date/time/datetime — a regex or a
99
+ strftime string or a preset Symbol), `round:` (float/decimal — Integer places),
100
+ `base:` (integer — radix 2..36 for string input, default 10), `strict:` (string-shaped
101
+ and integer — Boolean; see [Strict coercion](#strict-coercion-strict-true)),
102
+ `values:` (boolean — a `{truthy:, falsy:}` Hash or a preset Symbol), `of:` (array
103
+ element type / union member array), `element:` (array — a Hash of the element
104
+ type's own options; see [Element options](#element-options-element)), `enum:` (string-like — Array of allowed values),
105
+ `in:` (numeric/temporal — Array or Range), `zone:` (time/datetime — `"UTC"`,
106
+ `"±HH:MM"` or an IANA name like `"America/Denver"` for zone-less input; see [Zones](#zones-zone)).
107
+
108
+ ```ruby
109
+ required :joined, :date, format: '%m/%d/%Y' # custom strftime
110
+ required :paid, :boolean, values: :english_yes_no
111
+ required :flag, :boolean, values: { truthy: %w[on], falsy: %w[off] }
112
+ ```
113
+
114
+ Boolean presets: `:english_yes_no`/`:english` (`true yes y on 1` / `false no n off 0`),
115
+ `:numeric` (`1`/`0`). Date presets: `:iso8601 :us :eu`. Time/DateTime presets:
116
+ `:iso8601 :rfc2822 :db`.
117
+
118
+ ### The numeric contract
119
+
120
+ - **Integer strings are base 10.** `"010"` is 10 and `"007"` is 7 — not octal —
121
+ and `"0x1A"` / `"0b11"` are refused rather than read as hex/binary. Surrounding
122
+ whitespace and digit underscores are fine (`" 42 "` → 42, `"1_000"` → 1000).
123
+ Declare `base:` (2..36, checked at class load) for another radix:
124
+ `field :code, :integer, base: 16` reads `"1A"` as 26. Integers pass through and
125
+ Floats still truncate; `base:` only affects strings.
126
+ - **Float strings are base 10 too.** `"0x1A"` (which `Kernel.Float` reads as 26.0)
127
+ is refused, like `"0b101"` and `"0o17"`. Every other `Kernel.Float` form still
128
+ reads: `"1_000.5"`, `" 2.5 "`, `"1e3"`, `".5"`, `"017"` (→ 17.0).
129
+ - **NaN and ±Infinity are never valid.** `:integer`, `:float` and `:decimal` refuse
130
+ them — as a Float, a BigDecimal, a String (`"NaN"`, `"Infinity"`) or JSON `1e400` —
131
+ as an ordinary coercion failure under the field's `coercion_policy`. A `:union`
132
+ refuses a non-finite Float/BigDecimal outright rather than letting a `:string`
133
+ member store `"NaN"`. Like every FieldStruct message, these name the input class,
134
+ never the value — see [Log safety](#log-safety-error-messages-and-sensitive-fields).
135
+ - **`round:` keeps the field's type and rounds half up** (ties away from zero).
136
+ `round: 0` returns a `Float` / `BigDecimal`, not an Integer, and `:decimal`
137
+ ignores the process-global `BigDecimal.mode` — a host that sets banker's rounding
138
+ still gets `12.345` → `12.35` at `round: 2`.
139
+
140
+ ### Strict coercion (`strict: true`)
141
+
142
+ The defaults are lenient on purpose — they match what ActiveModel does, so a Rails
143
+ client sees the same values. A field that must not accept "close enough" (amounts,
144
+ account numbers) can opt in per field with `strict: true`. Strict only *narrows*:
145
+ anything it accepts becomes exactly what the default would make of it.
146
+
147
+ | Input | `:string` | `:string, strict: true` | `:integer` | `:integer, strict: true` |
148
+ |-------|-----------|-------------------------|------------|--------------------------|
149
+ | `"abc"` / `"42"` | `"abc"` / `"42"` | same | — / 42 | — / 42 |
150
+ | `:abc` | `"abc"` | `"abc"` | `TypeError` | `TypeError` |
151
+ | `42` | `"42"` | `"42"` | 42 | 42 |
152
+ | `12.0` / `BigDecimal("12.0")` | `"12.0"` / `"0.12e2"` | `TypeError` | 12 / 12 | 12 / 12 |
153
+ | `12.7` / `Rational(25, 2)` | `"12.7"` / `"25/2"` | `TypeError` | 12 (truncated) | `ArgumentError` |
154
+ | `"12.7"` / `"12.0"` / `"1e3"` / `"0x1A"` | as-is | as-is | `ArgumentError` | `ArgumentError` |
155
+ | object with `to_str` | `to_s` | `to_s` | read as its `to_str` string | read as its `to_str` string |
156
+ | `{a: 1}` / `[1]` / `true` / `Time` / `Object.new` | their `to_s` | `TypeError` | `TypeError` (a `Time` reads as its epoch) | `TypeError` (a `Time` → `ArgumentError`) |
157
+ | `Float::NAN` / `Infinity` | `"NaN"` / `"Infinity"` | `TypeError` | `ArgumentError` | `ArgumentError` |
158
+
159
+ - **`:string` strict** accepts a String, a Symbol, an Integer and anything
160
+ responding to `to_str`. An Integer counts because a JSON payload often carries an
161
+ identifier as a number (`"account": 12345`) and its text is unambiguous. A Float,
162
+ BigDecimal or Rational is refused: its `to_s` (`"1.0e+20"`, `"0.12e2"`, `"1/2"`)
163
+ is not the text the sender meant. Booleans, Hashes,
164
+ Arrays, Complex and other objects are refused — their `to_s` is a rendering, not
165
+ a value. It applies to the string subtypes too (`:immutable_string`, `:uuid`,
166
+ `:url`, `:email`, `:binary`).
167
+ - **`:integer` strict** accepts a value only when it *is* a whole number: an
168
+ Integer, or a numeric equal to its integer (`12.0`, `BigDecimal("12")`,
169
+ `Rational(24, 2)`). Strings follow the default rules exactly — integer digits in
170
+ base 10 or `base:` — so a float-shaped string like `"12.0"` or `"1e3"` is refused
171
+ in both modes.
172
+ - Refusals are ordinary coercion failures under the field's `coercion_policy`, and
173
+ their messages name the input class, never the value.
174
+ - `strict:` must be `true`/`false` (checked at declaration); on any other type it
175
+ raises `strict: option does not apply to Float fields. It applies to: String, ImmutableString, Integer, …`
176
+
177
+ <!-- doctest -->
178
+ ```ruby
179
+ class Charge < FieldStruct::Base
180
+ required :reference, :string, strict: true
181
+ required :cents, :integer, strict: true
182
+ end
183
+
184
+ Charge.new(reference: :ord_1, cents: 1250.0).cents # => 1250
185
+ Charge.new(reference: { id: 1 }, cents: 1).valid? # => false
186
+ Charge.new(reference: 'ord_1', cents: 12.5).valid? # => false
187
+ ```
188
+
189
+ ### The temporal contract
190
+
191
+ Three rules apply to `:date` / `:time` / `:datetime`, and they exist because the
192
+ stdlib parsers answer questions they were not asked.
193
+
194
+ - **A declared `format:` anchors.** `'2026-07-031'` under `'%Y-%m-%d'` is refused
195
+ rather than read as the 3rd with the trailing character dropped. "Anchor" means
196
+ trailing-junk rejection and nothing more — a hand-written format stays
197
+ strptime-lenient about widths and sign, because `%m` accepting one or two digits
198
+ is strptime's contract.
199
+ - **A day that never existed is refused**, by all three types. `'2026-06-31'` is
200
+ not 1 July.
201
+ - **With no declared format, the string must name a whole day.** `Time.parse('10:30')`
202
+ is *today* at 10:30 and `'July'` is 1 July of this year; a value whose meaning
203
+ depends on when it was parsed is not a value. A *declared* partial format
204
+ (`format: '%H:%M'`) is a deliberate choice and still works.
205
+
206
+ A blank string is a coercion failure, as it already was for `:integer`, `:float`,
207
+ `:big_decimal` and `:boolean`. Only `:string` treats blank as missing.
208
+
209
+ **`format: :iso8601` is RFC 3339**, which is what "ISO-8601" means in an API
210
+ contract and what JSON Schema's `date-time` refers to. FieldStruct parses and
211
+ renders it itself rather than delegating to `Time.iso8601` / `DateTime.iso8601`,
212
+ which disagree with each other (and `Time.iso8601` reads `2026-02-30T10:30:00Z`
213
+ as 2 March). It accepts full-width fields, a `T` separator, whole seconds,
214
+ optional fractional seconds, and a **mandatory** offset (`Z` or `±hh:mm`); it
215
+ refuses `2026-7-3`, `+2026-07-03`, `20261-07-03`, `20260703`, week and ordinal
216
+ dates, a missing offset, hour 24 and second 60. Output is `Z` for UTC and
217
+ `+HH:MM` otherwise.
218
+
219
+ ```ruby
220
+ required :on, :date, format: :iso8601 # 2026-07-03 exactly, and a real day
221
+ required :at, :datetime, format: :iso8601 # 2026-07-03T10:30:00Z, offset required
222
+ ```
223
+
224
+ That strictness is the point: it is what lets you declare the field instead of
225
+ hand-rolling `format: /\A\d{4}-\d{2}-\d{2}\z/` plus a validator. The other
226
+ presets (`:us`, `:eu`, `:db`, `:rfc2822`) are display and interchange formats, so
227
+ they stay strftime-based — with anchoring and civil-date validity still applied.
228
+
229
+ ### Zones (`zone:`)
230
+
231
+ By default a zone-less string follows the stdlib semantics of the type you
232
+ declared: `:time` uses `Time.parse` and lands in the *process* zone, `:datetime`
233
+ uses `DateTime.parse` and lands in **UTC**. So `"2026-10-22 10:00"` in a `:time`
234
+ field is a different instant on a server in Denver than on one in Tokyo.
235
+
236
+ Declare `zone:` on a `:time` or `:datetime` field to name the zone a zone-less
237
+ value means. The same input then gives the same answer on any server. `zone:`
238
+ only fills in a **missing** offset — it never changes one the input carries.
239
+
240
+ `zone:` takes `"UTC"`, a fixed offset (`"-06:00"`, the same all year) or an IANA
241
+ name (`"America/Denver"`, which follows daylight saving: -06:00 in July, -07:00 in
242
+ January). With a name, a wall-clock time gets the offset in force **at that date**.
243
+
244
+ | Input | no `zone:` | `zone: "-06:00"` | `zone: "America/Denver"` |
245
+ |-------|------------|------------------|--------------------------|
246
+ | `"2026-10-22 10:00"` (no offset) | `:time` process zone; `:datetime` UTC | 10:00 at -06:00 | 10:00 at Denver's offset that day (`"2026-01-15 10:00"` → -07:00) |
247
+ | a `format:` without `%z` (`:db`, `'%H:%M'`) | as above | 10:00 at -06:00; a date the format omits is *today in -06:00* | the same, at that date's offset; an omitted date is *today in Denver* |
248
+ | `"2026-10-22T10:00:00+02:00"`, `"… Z"`, `"… EST"` | its own offset | its own offset (unchanged) | its own offset (unchanged) |
249
+ | `"2026-10-22 10:00 XYZ"`, `"… America/Denver"` (a zone in the *string*) | `:time` process zone; `:datetime` UTC | refused — `zone:` won't override a stated zone | refused, likewise |
250
+ | `Time` / `DateTime` / TimeWithZone | instant and offset kept | instant and offset kept (not converted) | instant and offset kept (not converted) |
251
+ | `Date` | midnight: `:time` process zone, `:datetime` UTC | midnight at -06:00 | midnight at that day's offset |
252
+ | an epoch string under `format: '%s'` | that instant, in the process zone (`:time`) or UTC (`:datetime`) | that instant, at -06:00 | that instant, at Denver's offset at that instant |
253
+ | Integer (epoch seconds) | refused | refused | refused |
254
+
255
+ - **Accepted values:** `"UTC"` (the `:time` value is then `utc?`), fixed offsets
256
+ `"+HH:MM"` / `"-HH:MM"` from `-23:59` to `+23:59` (colon required), and IANA
257
+ `Area/Location` names in the zone database (`"America/Denver"`, `"Etc/UTC"`,
258
+ `"America/Argentina/Buenos_Aires"`). Anything else raises `ArgumentError` at
259
+ declaration, naming the option: `"utc"`, `"Z"`, `"-0600"`, an unknown name, and
260
+ abbreviations or legacy aliases with no `/` — `"MST"` is in the zone database,
261
+ but as a fixed -07:00, not Mountain time with DST, so it is refused rather than
262
+ quietly meaning something else.
263
+ - **Rails zone names are refused** (`"Mountain Time (US & Canada)"`). They are
264
+ ActiveSupport aliases that exist only when ActiveSupport is loaded, so accepting
265
+ them would make the same declaration valid in one process and invalid in another.
266
+ Use the IANA name they map to (`ActiveSupport::TimeZone::MAPPING[name]`).
267
+ - **Daylight-saving edges** (names only — a fixed offset has none):
268
+ - A wall-clock time the zone **skips** (`"2026-03-08 02:30"` in Denver, inside the
269
+ spring-forward hour) is refused: `could not be coerced: names a local time that
270
+ does not exist in America/Denver (skipped by a daylight-saving change)`. It names
271
+ no instant, like the 30th of February. ActiveSupport moves it an hour forward
272
+ (03:30); FieldStruct does not guess.
273
+ - A wall-clock time the zone **repeats** (`"2026-11-01 01:30"`, which happens at
274
+ -06:00 and again at -07:00) is read at the **daylight-time** offset, the earlier
275
+ instant — what ActiveSupport's `TimeWithZone` picks, so a Rails app sees the
276
+ same answer.
277
+ - A `Date` whose midnight the zone skips becomes the **first moment** of that day
278
+ (01:00 -03:00 in `America/Santiago` on 6 September 2026), as `Date#in_time_zone` does.
279
+ - **What you get back** is a plain `Time` (or `DateTime`) with that offset — never a
280
+ `TZInfo::TimeWithOffset`, and it does not remember the zone: `Time#zone` is `nil`
281
+ and adding a day keeps the offset rather than following DST. Re-assign to re-read.
282
+ - **Dependency:** the zone database is [tzinfo](https://github.com/tzinfo/tzinfo)'s
283
+ (a runtime dependency since 0.14), required only when a field declares a name — a
284
+ `"UTC"` or fixed-offset field never loads it. tzinfo reads the OS zoneinfo
285
+ (`/usr/share/zoneinfo`); on a system without it (Windows, slim containers) a
286
+ declaration raises `ArgumentError` saying to add the `tzinfo-data` gem.
287
+ - `zone:` has no effect with `format: :iso8601`, which requires an offset anyway.
288
+ It does not apply to `:date` (a date has no time of day to place).
289
+
290
+ <!-- doctest -->
291
+ ```ruby
292
+ class Shift < FieldStruct::Base
293
+ required :starts, :time, zone: '-06:00'
294
+ optional :logged, :datetime, zone: 'UTC'
295
+ optional :ends, :time, zone: 'UTC', format: :db
296
+ optional :local, :time, zone: 'America/Denver'
297
+ end
298
+
299
+ shift = Shift.new(starts: '2026-10-22 10:00', ends: '2026-10-22 18:30:00')
300
+ shift.starts.utc_offset # => -21600
301
+ shift.starts.hour # => 10
302
+ shift.ends.utc? # => true
303
+ Shift.new(starts: '2026-10-22T10:00:00+02:00').starts.utc_offset # => 7200
304
+ Shift.new(starts: '2026-10-22 10:00', logged: '2026-10-22 10:00').logged.offset # => 0
305
+
306
+ Shift.new(starts: '2026-07-01 10:00', local: '2026-07-01 10:00').local.utc_offset # => -21600
307
+ Shift.new(starts: '2026-01-15 10:00', local: '2026-01-15 10:00').local.utc_offset # => -25200
308
+ Shift.new(starts: '2026-11-01 10:00', local: '2026-11-01 01:30').local.utc_offset # => -21600
309
+ gap = Shift.new(starts: '2026-03-08 10:00', local: '2026-03-08 02:30')
310
+ gap.errors[:local].first.end_with?('does not exist in America/Denver (skipped by a daylight-saving change)') # => true
311
+ ```
312
+
313
+ ---
314
+
315
+ ## Class macros
316
+
317
+ ```ruby
318
+ class Doc < FieldStruct::Base
319
+ immutable! # setters raise ImmutableError after initialize
320
+ frozen! # Ruby-freeze the instance at end of initialize (stackable with immutable!)
321
+ coercion_policy :replace # class-wide coercion-failure policy; inherited by subclasses
322
+ unknown_attributes :raise # reject unknown input keys (default :ignore = drop them)
323
+
324
+ required :title, :string
325
+ required :slug, :string
326
+
327
+ serialize :json, title: 'docTitle' # map internal field -> external JSON key (:json wired in)
328
+
329
+ validate :slug_matches_title # cross-field validator (method symbol)
330
+ validate { |rec| rec.errors.add(:base, 'empty') if rec.title.to_s.empty? } # or block
331
+
332
+ def slug_matches_title
333
+ errors.add(:base, 'slug/title mismatch') unless slug == title.downcase
334
+ end
335
+ end
336
+ ```
337
+
338
+ - `coercion_policy` / `unknown_attributes` are read with no arg, set with one.
339
+ - **Inheritance:** all four macros inherit to subclasses; a child overrides by re-declaring, and the override never mutates the parent. `coercion_policy`/`unknown_attributes` override to any value; `immutable!`/`frozen!` are **one-way** — a child can add them but can't un-set an inherited one (no `mutable!`/`unfrozen!`).
340
+ - `validate` runs at `valid?` (and at construct if any exist); convention is `errors.add(:base, ...)`; `errors[:base]` is cleared each `valid?` run.
341
+
342
+ ### Payment and API boundaries: `unknown_attributes :raise`
343
+
344
+ At a trust boundary (a payment request, a webhook, a partner API body) declare
345
+ `unknown_attributes :raise`. The default, `:ignore`, drops keys it doesn't know
346
+ without telling you. So a misspelled key (`"ammount"`) or a renamed field upstream
347
+ leaves a field unset, and a field you haven't modelled yet (`"cvv"`) goes away without
348
+ a trace. With `:raise`, `new` / `from_json` / `assign_attributes` / `with` fail with an
349
+ `UnknownAttributeError` whose `keys` name the extras (names only, never values).
350
+
351
+ - **Declare it on every struct that reads input**, nested ones too. It is inherited
352
+ by *subclasses*, not by structs a field nests: a nested class without it still
353
+ drops its own unknown keys. The nested error propagates out of the parent's
354
+ `new` rather than going through the parent's `coercion_policy`.
355
+ - **`from_json` reverse-maps `serialize :json` names first**, so external names
356
+ (`"amountDecimal"`) are known keys there. Passing them to `new` raises, because
357
+ `new` takes canonical names.
358
+ - Keep `:ignore` where extra keys are expected and harmless, e.g. a struct reading
359
+ a few fields out of a large third-party payload.
360
+
361
+ <!-- doctest -->
362
+ ```ruby
363
+ class Card < FieldStruct::Base
364
+ unknown_attributes :raise
365
+ required :last4, :string
366
+ end
367
+
368
+ class Charge < FieldStruct::Base
369
+ unknown_attributes :raise
370
+ required :amount, :decimal
371
+ required :card, Card
372
+ serialize :json, amount: 'amountDecimal'
373
+ end
374
+
375
+ Charge.from_json('{"amountDecimal":"9.99","card":{"last4":"4242"}}').valid? # => true
376
+
377
+ typo = begin
378
+ Charge.from_json('{"ammount":"9.99","card":{"last4":"4242"}}')
379
+ rescue FieldStruct::UnknownAttributeError => e
380
+ e
381
+ end
382
+ typo.keys # => [:ammount]
383
+ typo.message # "unknown attribute(s) for Charge: :ammount"
384
+
385
+ nested = begin
386
+ Charge.from_json('{"amountDecimal":"9.99","card":{"last4":"4242","cvv":"123"}}')
387
+ rescue FieldStruct::UnknownAttributeError => e
388
+ e
389
+ end
390
+ nested.keys # => [:cvv]
391
+ nested.message # "unknown attribute(s) for Card: :cvv"
392
+ ```
393
+
394
+ ---
395
+
396
+ ## Instance surface
397
+
398
+ ```ruby
399
+ u = User.new(name: 'Alice', age: '30') # Hash arg; Symbol or String keys
400
+ u.name; u.age = 31 # typed reader; permissive setter (coerces)
401
+ u.attributes # => { name: "Alice", age: 31 } (alias: to_h)
402
+ u.attribute_names # => [:name, :age]
403
+ u.valid? # runs cross-field validators, then errors.empty?
404
+ u.invalid?
405
+ u.errors # FieldStruct::Errors
406
+ u.inspect # #<User name: "Alice" age: 31>; an invalid instance appends
407
+ # `errors: {name: ["is required"]}` so it never looks fine
408
+ u == other # value equality (same class + same attributes; errors ignored)
409
+ u.as_json # JSON-ready Hash with :json mapping applied
410
+ u.to_json # String (via Oj)
411
+ User.from_json(str) # parse + reverse-map :json keys -> instance
412
+ u.assign_attributes(age: 40) # bulk update through the setter pipeline
413
+ u.with(age: 41) # NEW instance with age changed; u untouched
414
+ case u; in { name: 'Alice', age: } then age; end # pattern matching
415
+ ```
416
+
417
+ ### Copying with changes (`with`)
418
+
419
+ `with(**changes)` returns a new instance of the receiver's class with the given
420
+ attributes changed, like `Data#with`. The receiver is never modified, so it is the
421
+ way to "update" an `immutable!` or `frozen!` instance.
422
+
423
+ - **Changed fields go through the setter pipeline**: coercion, the field's
424
+ `coercion_policy` (under `:raise` the `CoercionError` propagates and no copy is
425
+ returned), and that field's errors rewritten. Cross-field `validate` blocks rerun.
426
+ - **Unchanged fields are carried over as they are**, errors included, exactly as
427
+ `dup` copies them. Callable defaults are not called again.
428
+ - **Shallow**, like `dup`: an unchanged nested struct or array is the *same object*
429
+ in both instances. Mutating one through the other is visible in both.
430
+ - **Unknown keys** follow `unknown_attributes` (dropped by default, `UnknownAttributeError`
431
+ under `:raise`). String keys work too: `with('age' => 41)`.
432
+ - **Keywords only**: `with({age: 41})` raises `ArgumentError`; splat a Hash with
433
+ `with(**hash)`. `with` with no changes returns an equal copy, not `self`.
434
+ - The copy is immutable when the class is, and frozen when the receiver is
435
+ frozen. `with` (and `dup`, which it relies on) are reserved field names.
436
+
437
+ <!-- doctest -->
438
+ ```ruby
439
+ class Point < FieldStruct::Base
440
+ immutable!
441
+ required :x, :integer
442
+ required :y, :integer
443
+ end
444
+
445
+ origin = Point.new(x: 0, y: 0)
446
+ moved = origin.with(x: '5')
447
+ moved.x # => 5
448
+ origin.x # => 0
449
+ moved.class # => Point
450
+ origin.with(y: nil).valid? # => false
451
+ origin.valid? # => true
452
+ ```
453
+
454
+ `Errors` API: `errors[:field] # => Array<String>`, `errors.add(:field, 'msg')`,
455
+ `errors.clear(:field)`, `errors.empty?`, `errors.to_h` (alias `messages`),
456
+ `errors.full_messages` (each message as a sentence — humanized field name
457
+ prepended, e.g. `first_name`/`is required` → `"First name is required"`; `:base`
458
+ messages pass through unprefixed).
459
+
460
+ ---
461
+
462
+ ## Validation semantics (important)
463
+
464
+ - **Per-field, on every assignment.** The setter owns its field's errors — it clears
465
+ and rewrites `errors[name]` each time you assign.
466
+ - **Required + missing** → `errors.add(name, 'is required')`.
467
+ - **Present but invalid** → `errors.add(name, 'is invalid')`. "Invalid" = `format:`
468
+ regex mismatch, `enum:`/`in:` violation, an invalid nested struct, or an array with
469
+ an invalid element. An array element breaking its `element:` `format:`/`enum:`/`in:`
470
+ is reported by index: `'element at index 2 is invalid'`. So **"required" means present *and* valid**.
471
+ - **Validity is separate from construction.** A field can hold `nil`/raw even when
472
+ required — see coercion policy below. Always gate on `valid?`, don't assume a
473
+ required reader is non-nil.
474
+
475
+ ### Validity is computed on assignment (shallow `valid?` staleness)
476
+
477
+ A field's errors are written when *that field* is assigned, and `valid?` only reads
478
+ them (it reruns cross-field `validate` blocks, nothing else). A parent records an
479
+ invalid nested struct or array element when the nested value is assigned. **It
480
+ doesn't watch it afterwards.** Mutating a nested struct, or an array element, in
481
+ place leaves the parent's errors as they were, in both directions:
482
+
483
+ - the parent stays `valid?` after a nested field is broken in place, and
484
+ - stays invalid after a nested problem is fixed in place.
485
+
486
+ Pushing onto an array in place (`record.tags << 5`) also skips coercion: the raw
487
+ value is stored as-is.
488
+
489
+ **Workaround: assign the field again** (or use `with`/`assign_attributes`, which go
490
+ through the setter). That reruns coercion and validation for that field.
491
+
492
+ <!-- doctest -->
493
+ ```ruby
494
+ class Address < FieldStruct::Base
495
+ required :city, :string
496
+ end
497
+
498
+ class Person < FieldStruct::Base
499
+ required :address, Address
500
+ optional :tags, :array, of: :string
501
+ end
502
+
503
+ person = Person.new(address: { city: 'Provo' })
504
+ person.address.city = nil # mutate the nested struct in place
505
+ person.address.valid? # => false
506
+ # Stale: the parent never saw the change.
507
+ person.valid? # => true
508
+
509
+ person.address = person.address # re-assign: the setter re-validates
510
+ person.valid? # => false
511
+ person.errors[:address] # => ['is invalid']
512
+
513
+ person.address.city = 'Provo' # fixed in place...
514
+ # ...and stale the other way.
515
+ person.valid? # => false
516
+ person.with(address: person.address).valid? # => true
517
+
518
+ person.tags = ['a']
519
+ person.tags << 5 # in-place push: no coercion
520
+ person.tags # => ['a', 5]
521
+ person.tags = person.tags # re-assign coerces each element
522
+ person.tags # => ['a', '5']
523
+ ```
524
+
525
+ For a nested struct you mutate often, keep it `immutable!` (or `frozen!`) and use
526
+ `with` to change it. Then the only way to change it goes through a setter.
527
+
528
+ ### Coercion failure policy
529
+
530
+ When a value can't be coerced into the field's type:
531
+
532
+ | Policy | Stored value | Error added | Notes |
533
+ |--------|--------------|-------------|-------|
534
+ | `:keep_raw` (default) | the raw input | yes (`could not be coerced: …`) | nothing lost; inspect & fix |
535
+ | `:replace` | `nil` | yes | clean slate |
536
+ | `:raise` | — | — | raises `FieldStruct::CoercionError` |
537
+
538
+ Messages name the field, the expected type and the input's *class*, never the value:
539
+ `could not be coerced: Integer fields expect a base-10 integer, got a String that is
540
+ not one`; under `:raise`, `could not coerce String input for cents: …`.
541
+
542
+ ### Log safety: error messages and sensitive fields
543
+
544
+ Payments data must not reach a log line, so **no message FieldStruct builds echoes an
545
+ input value** — not an `errors` entry, not a raised error (`CoercionError`, a type's
546
+ `ArgumentError`/`TypeError`, `from_json`'s errors), and not their `cause` chain, which
547
+ error reporters print. Kernel conversion errors (`invalid value for Integer(): "…"`)
548
+ are dropped from the chain; an invalid-JSON error from `from_json` keeps Oj's error
549
+ class and position but not the quoted input. Declared schema (a format, a boolean's
550
+ accepted strings, a field name) is still named. A custom type owns its own messages:
551
+ raise with the value's class, not its `inspect`.
552
+
553
+ `sensitive: true` (any type, `true`/`false` only) masks the value where objects get
554
+ logged — `inspect`, and so `pp`, the `errors:` suffix of an invalid instance, nested
555
+ structs and arrays of them. `nil` still shows, so a missing value stays visible:
556
+
557
+ <!-- doctest -->
558
+ ```ruby
559
+ class Card < FieldStruct::Base
560
+ required :number, :string, sensitive: true
561
+ optional :cvv, :string, sensitive: true
562
+ optional :brand, :string
563
+ end
564
+
565
+ card = Card.new(number: '4111111111111111', brand: 'visa')
566
+ card # => #<Card number: [FILTERED], cvv: nil, brand: "visa">
567
+ card.inspect.include?('4111') # => false
568
+ card.number # => "4111111111111111"
569
+ card.to_h # => { number: "4111111111111111", cvv: nil, brand: "visa" }
570
+ Card.metadata[:number].sensitive? # => true
571
+ ```
572
+
573
+ Data paths are **not** masked — the reader, `attributes`/`to_h`, `as_json`/`to_json`,
574
+ `deconstruct_keys` and equality all return the real value, so `from_json(to_json)`
575
+ still round-trips. Masking there would corrupt the data; filter at the log boundary
576
+ if you log those. `Field#inspect`, `Metadata#to_h` (`sensitive: true`) and `describe`
577
+ show the flag only when it is set.
578
+
579
+ ---
580
+
581
+ ## Nested, arrays, unions
582
+
583
+ ```ruby
584
+ class Address < FieldStruct::Base
585
+ required :city, :string
586
+ end
587
+
588
+ class Person < FieldStruct::Base
589
+ required :name, :string
590
+ optional :address, Address # nested: Hash or Address instance
591
+ optional :aliases, :array, of: :string # array of scalars
592
+ optional :homes, :array, of: Address # array of nested (each coerced + validated)
593
+ required :id, :union, of: %i[string integer] # union: first member that accepts it
594
+ end
595
+
596
+ Person.new(name: 'A', address: { city: 'NYC' }).address # => #<Address city: "NYC">
597
+ ```
598
+
599
+ Nested validity propagates: a `Person` with an invalid `address` is itself invalid.
600
+ Pattern matching recurses (`in { address: { city: 'NYC' } }`).
601
+
602
+ A refused array element is named by its index (0-based), never its value:
603
+ `errors[:ids] # => ["could not be coerced: element at index 1: Integer fields expect a base-10 integer, got a String that is not one"]`.
604
+ When several are refused, every index is listed and the first one's reason is
605
+ shown — `elements at index 1, 4, 7 (index 1 shown): …` — up to five indexes, then
606
+ a count (`elements at index 0, 1, 2, 3, 4 and 15 more (index 0 shown): …`). The
607
+ same message reaches every `coercion_policy`, `:raise` included.
608
+
609
+ ### Element options (`element:`)
610
+
611
+ `element:` takes the options a scalar field of the element type would take, and
612
+ applies them to every element — coercion options (`strict:`, `base:`, `round:`,
613
+ `format:`, `zone:`, `values:`) and validation options (`format:` Regexp, `enum:`,
614
+ `in:`) alike. Array-level options stay on the field (`required:`, `default:`,
615
+ `coercion_policy:`, `sensitive:`, …).
616
+
617
+ <!-- doctest -->
618
+ ```ruby
619
+ class Batch < FieldStruct::Base
620
+ optional :ids, :array, of: :integer, element: { strict: true, base: 16 }
621
+ optional :days, :array, of: :date, element: { format: '%d/%m/%Y' }
622
+ optional :codes, :array, of: :string, element: { format: /\A[A-Z]{3}\z/ }
623
+ end
624
+
625
+ Batch.new(ids: %w[ff 10]).ids # => [255, 16]
626
+ Batch.new(ids: [1, 2.5]).errors[:ids] # => ["could not be coerced: element at index 1: Integer fields with strict: true expect a whole number, got a Float that is not one"]
627
+ Batch.new(codes: %w[USD eur]).errors[:codes] # => ["element at index 1 is invalid"]
628
+ Batch.new(codes: %w[usd EUR gbp]).errors[:codes] # => ["elements at index 0, 2 are invalid"]
629
+ Batch.new(ids: [1.5, 2, 'x']).errors[:ids] # => ["could not be coerced: elements at index 0, 2 (index 0 shown): Integer fields with strict: true expect a whole number, got a Float that is not one"]
630
+ Batch.new(days: ['22/10/2026']).as_json[:days] # => ['22/10/2026']
631
+ Batch.metadata.to_h[:ids][:options] # => { of_type: 'Integer', element: { strict: true, base: 16 } }
632
+ ```
633
+
634
+ They are checked at declaration against the element type's own schema, with the
635
+ error a scalar field would get, prefixed with the field: `element: { base: '16' }`
636
+ raises `in element: of array field :ids, base: on Integer expects Integer, got String
637
+ ("16")`. A key the element type doesn't take raises, listing the ones it does
638
+ (`round: is not an Integer option. Integer accepts: in, base, strict.`) — unlike a
639
+ field, `element:` passes nothing through. A temporal element's `format:` is also
640
+ what `to_json` writes, so `from_json` reads it back. `describe` lists what each
641
+ array's elements accept, by name (`element accepts in, base, strict`); the shapes
642
+ are the element type's own (`FieldStruct::Types::Integer.option_schema`).
643
+ Not supported: `element:` on an `:array` of arrays, and on a nested struct element
644
+ (a struct takes no options).
645
+
646
+ ---
647
+
648
+ ## JSON & serialization
649
+
650
+ - `as_json`/`to_json` apply the `:json` `serialize` mapping (forward); `from_json`
651
+ reverse-maps. Round-trips for every base type **except `:value`** (no type info to
652
+ restore).
653
+ - Conversions in `as_json`: `Date`/`Time`/`DateTime` → ISO-8601 (or the field's
654
+ `format:`), `BigDecimal` → plain string, `Symbol` → String, arrays/nested recurse.
655
+ - `from_json` parses JSON numbers with a fraction or exponent as `BigDecimal`, so a
656
+ `:decimal` gets the exact digits sent (`1234567890123.4567`, not `…123.46`).
657
+ `:float` still holds a Float; a `:value` field (and numbers inside its Hashes and
658
+ Arrays) holds the `BigDecimal` — which `to_json` writes back as a string.
659
+
660
+ <!-- doctest -->
661
+ ```ruby
662
+ class U < FieldStruct::Base
663
+ required :first, :string
664
+ serialize :json, first: 'firstName'
665
+ end
666
+ U.new(first: 'Al').to_json # => '{"firstName":"Al"}'
667
+ U.from_json('{"firstName":"Al"}') # => #<U first: "Al">
668
+ ```
669
+
670
+ ---
671
+
672
+ ## Custom types & namespaces
673
+
674
+ A namespace exposes `self.field_types` (a registry parented to the base set); fields
675
+ declared on classes inside that namespace resolve names through it.
676
+
677
+ ```ruby
678
+ module Acme
679
+ def self.field_types
680
+ @field_types ||= FieldStruct.new_registry do # parent defaults to FieldStruct.types
681
+ register :money, Acme::Types::Money # add a type
682
+ register :dec, :big_decimal # alias to an existing name
683
+ end
684
+ end
685
+
686
+ class Invoice < FieldStruct::Base
687
+ required :total, :money # resolved via Acme.field_types
688
+ end
689
+ end
690
+ ```
691
+
692
+ A custom type is a `FieldStruct::Types::Base` subclass implementing `#coerce` and
693
+ `#ruby_type` (override `#missing?` for a broader notion than nil-only).
694
+
695
+ Optional short alias: `FieldStruct.use_alias!(:FS)` defines top-level `FS` →
696
+ `FieldStruct` and switches inspect output to the short prefix.
697
+
698
+ ---
699
+
700
+ ## Introspection
701
+
702
+ ```ruby
703
+ User.metadata # => #<FieldStruct::Metadata fields=[:name, :age]>
704
+ User.metadata[:age] # => #<FieldStruct::Field :age Integer>
705
+ User.attribute_names # => [:name, :age]
706
+ field = User.metadata[:age]
707
+ field.name; field.type; field.required?; field.default; field.options; field.type_instance.ruby_type
708
+ ```
709
+
710
+ ---
711
+
712
+ ## RBS for your own classes
713
+
714
+ Sord types the library; `FieldStruct::RBS.generate` types the accessors **your**
715
+ `field` declarations create, so Steep/Solargraph can check `order.items`:
716
+
717
+ ```ruby
718
+ class Order < FieldStruct::Base
719
+ required :id, :uuid
720
+ optional :items, :array, of: LineItem
721
+ optional :status, :union, of: %i[symbol boolean]
722
+ optional :scope, :value, rbs_type: '::ActiveRecord::Relation'
723
+ optional :extra, :value
724
+ end
725
+
726
+ puts FieldStruct::RBS.generate(Order)
727
+ # # Generated by FieldStruct::RBS. Types describe a valid instance: an
728
+ # # invalid one (a required field left unset, or a :keep_raw coercion
729
+ # # failure holding the raw input) can hold other values. Check valid?.
730
+ # class Order < ::FieldStruct::Base
731
+ # attr_reader id: ::String
732
+ # def id=: (::String? value) -> ::String?
733
+ #
734
+ # attr_reader items: ::Array[::LineItem]?
735
+ # def items=: (::Array[::LineItem]? value) -> ::Array[::LineItem]?
736
+ #
737
+ # attr_reader status: (::Symbol | bool)?
738
+ # def status=: ((::Symbol | bool)? value) -> (::Symbol | bool)?
739
+ #
740
+ # attr_reader scope: ::ActiveRecord::Relation?
741
+ # def scope=: (::ActiveRecord::Relation? value) -> ::ActiveRecord::Relation?
742
+ #
743
+ # attr_reader extra: untyped
744
+ # def extra=: (untyped value) -> untyped
745
+ # end
746
+ ```
747
+
748
+ - **Types describe a valid instance.** Reader = the field's Ruby type; nullability
749
+ follows `required?` (required → `T`, optional → `T?`). An invalid instance (unset
750
+ required field, `:keep_raw` failure holding raw input) can hold other values.
751
+ - **Writer** is `(T? value) -> T?` — the setter takes `nil` and returns the coerced
752
+ value. Loose input it would coerce (`user.age = "30"` on an `:integer` field) runs
753
+ fine but is a Steep error; coerce at the boundary.
754
+ - **Generics are filled in:** `::Array[::LineItem]` (a scalar element is nullable: `of: :string` → `::Array[::String?]`, because `["x", nil]` is valid), unions `(A | B)` (boolean →
755
+ `bool`), nested structs by qualified name. A custom type whose `ruby_type` is a bare
756
+ generic (`::Hash`) emits `::Hash[untyped, untyped]` unless the type defines
757
+ `rbs_type` (`def rbs_type = '::Hash[::Symbol, ::String]'`).
758
+ - **`:value`** (and a union with a `:value` member) is `untyped`, reader and writer.
759
+ - **`rbs_type:`** (field) beats the type's `rbs_type`, which beats the derived type.
760
+ It replaces `T` verbatim — `?` is still added for optional readers and all
761
+ writers, and a union override is parenthesized first. Must be a non-blank String
762
+ (checked at declaration; not parsed — `rbs` isn't a runtime dependency). Fully
763
+ qualify constants (`::ActiveRecord::Relation`).
764
+
765
+ `FieldStruct::RBS.type_for(field, nullable: nil)` returns one field's type exactly as
766
+ `generate` emits it, for generators that type methods taking or returning the field:
767
+
768
+ ```ruby
769
+ FieldStruct::RBS.type_for(Order.metadata[:items]) # => "::Array[::LineItem]?"
770
+ FieldStruct::RBS.type_for(Order.metadata[:items], nullable: false) # => "::Array[::LineItem]"
771
+ FieldStruct::RBS.type_for(Order.metadata[:id], nullable: true) # => "::String?"
772
+ ```
773
+
774
+ `nullable: nil` (default) follows `!field.required?`; `untyped` never gets `?`.
775
+ Generate RBS for nested/element classes too so `::LineItem` references resolve. See
776
+ the README "Type signatures" section for a Rake-task wiring example.
777
+
778
+ ---
779
+
780
+ ## Gotchas / invariants
781
+
782
+ - **Setters are permissive by design** — `user.age = "30"` is valid (coerces). The
783
+ generated writer is typed `(T? value) -> T?`, so Steep flags that line even though
784
+ Ruby accepts it.
785
+ - **A required field can still be `nil`** (coercion failed under `:keep_raw`/`:replace`,
786
+ or never assigned). Check `valid?`; don't assume required ⇒ non-nil.
787
+ - **Whitespace-only strings are "missing"** for `:string` and its subclasses
788
+ (`:uuid`/`:url`/`:email`/`:immutable_string`). `:binary` is the exception — its
789
+ whitespace bytes are real data, so only nil/empty count as missing.
790
+ - **`nil` passes through coercion as `nil`** for essentially every type.
791
+ - **Temporal coercion behaves the same with or without ActiveSupport loaded.**
792
+ That is a deliberate invariant, not a coincidence — see `.claude/project_intent.md`
793
+ invariant 8. Coercion dispatches on explicit stdlib classes rather than on
794
+ `respond_to?(:to_date)` and friends, which ActiveSupport defines on every String.
795
+ - `:array` requires `of:`; `:union` requires `of: [...]` with ≥2 members. Options for
796
+ each array element go in `element: { ... }`, not on the field.
797
+ - Option scoping: `format:` → string-shaped + date/time/datetime; `enum:` →
798
+ string/symbol; `in:` → integer/float/decimal/date/time/datetime; `round:` →
799
+ float/decimal; `base:` → integer; `strict:` → string-shaped/integer; `values:` → boolean; `element:` → array. Misapplying raises `ArgumentError` at declaration.
800
+ - `description:`/`desc:` is documentation only — absent from `attributes`, `as_json`,
801
+ and pattern-match keys.
802
+ - **Input is a Hash, or anything with `to_hash`** (e.g. a permitted
803
+ `ActionController::Parameters`). `new(nil)` is the same as `new`; any other
804
+ shape (`new([])`, `new("x")`, `assign_attributes(nil)`) raises `ArgumentError`
805
+ naming the class it got.
806
+ - **`dup` / `clone` copy the errors too** — the copy starts with the original's
807
+ messages but owns them, so changing one's top-level fields never changes the
808
+ other's `valid?`. The copy is shallow: nested structs are shared.
809
+ - **Some field names are reserved** — the ones `Base` calls on its own instances
810
+ (`errors`, `attributes`, `hash`, `class`, `send`, `to_h`, `inspect`, … — see
811
+ `FieldStruct::Base::RESERVED_FIELD_NAMES`). Declaring one raises `ArgumentError`
812
+ naming it; name the field something else and map the external key with
813
+ `serialize :json, errors_field: 'errors'`. `method`, `name`, `type` stay legal.
814
+ - Unknown input keys are dropped by default (`unknown_attributes :ignore`); set
815
+ `:raise` to reject them with `UnknownAttributeError` — recommended at payment/API
816
+ boundaries, on each struct (see [above](#payment-and-api-boundaries-unknown_attributes-raise)).
817
+ - **Validity is computed on assignment.** In-place mutation of a nested struct or an
818
+ array element doesn't refresh the parent's errors; re-assign the field (see
819
+ [staleness](#validity-is-computed-on-assignment-shallow-valid-staleness)).
820
+ - Uses **Oj** for JSON (`Oj.load`/`Oj.dump`), not the stdlib JSON.