FieldStruct
Typed POROs for Ruby — declare fields with enforced types, presence checks, and per-field validation. Mirrors the ActiveModel interface shape where it helps adoption, but reuses none of its code.
class User < FieldStruct::Base
required :name, :string
optional :age, :integer
optional :tags, :array, of: :string
end
u = User.new(name: 'Alice', age: '30', tags: %w[admin staff])
u.name # => "Alice"
u.age # => 30
u.tags # => ["admin", "staff"]
u.valid? # => true
u.attributes # => { name: "Alice", age: 30, tags: ["admin", "staff"] }
u.to_json # => '{"name":"Alice","age":30,"tags":["admin","staff"]}'
u.name = ''
u.valid? # => false
u.errors[:name] # => ["is required"]
u.errors.full_messages # => ["Name is required"]
inspect surfaces invalidity too: a valid instance reads cleanly
(#<User name: "Alice", age: 30, …>), while an invalid one appends its errors —
#<User name: "", age: 30, … errors: {name: ["is required"]}> — so an object
never looks fine when it isn't.
Installation
Add to your Gemfile:
gem 'field_struct'
Or install directly:
gem install field_struct
Requires Ruby 3.0+.
New here? docs/getting_started.md walks through
using FieldStruct in a repo (general Ruby + a Rails section). For the exhaustive
reference, USAGE.md is a dense, example-first cheat sheet — every type,
option, and macro on one page. Both ship with the gem (bundle show field_struct),
for editors and AI assistants to read.
What FieldStruct is
- A typed-value-object foundation: declare attributes, get coercion + validation for free.
- A class-level
Metadata collection per FieldStruct class, inspectable and introspectable.
- A pluggable type system, namespaced through a
Registry that downstream code can extend or replace.
- ActiveModel-shaped at the public surface (
valid?, errors, attributes, as_json, to_json, model_name, to_model) so Rails-adjacent code feels at home.
What FieldStruct is not
- Not a database layer. It doesn't persist, query, or hold connections.
- Not a form object. It doesn't render or know about HTTP params.
- Not an ActiveModel replacement. Interface shape only — no AM code reuse.
- Not a hash-schema validator. Validation is per-field-on-assignment, not "check this hash against a schema."
Declaring fields
Three macros declare fields; field is the primitive, required / optional are sugar that set the required: option.
class Account < FieldStruct::Base
field :id, :integer # neutral; defaults to optional
required :email, :string, format: /@/ # presence-checked + format
optional :nickname, :immutable_string # coerced, then frozen
optional :balance, :decimal, default: '0' # :decimal is an alias for :big_decimal
optional :created_at, :datetime
required :tags, :array, of: :string # element type is required
end
Base types
:string, :immutable_string, :integer, :float, :big_decimal (aliased as :decimal), :boolean, :date, :time, :datetime, :value, and :array (parameterized via of:).
Extended types: :symbol, :uuid, :url, :email, :binary. The first four serve string-shaped use cases — :uuid/:url/:email pre-fill a sensible format: regex (overrideable per-field), :binary forces ASCII-8BIT encoding and treats whitespace bytes as meaningful (not "missing").
:value is a passthrough — useful when you want metadata for a field without committing to a shape.
Union types
A field that may hold any of several types — each tried in declared order, first success wins:
class Event < FieldStruct::Base
optional :payload, :union, of: [Payload, :boolean]
optional :id, :union, of: %i[integer string]
end
Event.new(payload: { kind: 'click', value: 1 }).payload # => #<Payload ...>
Event.new(payload: true).payload # => true
Event.new(id: '42').id # => "42" (string first)
Members can be Symbols (registered scalars or FieldStruct subclasses) or Class arguments (FieldStruct subclasses). If every member rejects the value, the union raises and the parent's coercion_policy engages. Declared order matters — pick deliberately when types overlap (Integer accepts "42", String accepts "42" — the one listed first wins).
Nested FieldStructs
Pass a FieldStruct::Base subclass as the type to nest:
class Address < FieldStruct::Base
required :street, :string
required :city, :string
end
class Person < FieldStruct::Base
required :name, :string
required :address, Address
optional :addresses, :array, of: Address # arrays of nested too
end