Skip to content

Repository files navigation

Errgonomic

Errgonomic provides some lightweight, opinionated ergonomics for error handling in Ruby. These semantics are a blend of Rails present? conventions, and Rust Option and Result type combinators. Without going full Option and Result. Probably.

Design

Errgonomic aims at the intersection of two idioms rather than translating one into the other. Rails supplies the mechanism: a concern, an attribute reader overridden with super, the reader as the boundary of a model's public surface. Rust supplies the shape of the value: an Option you handle with combinators, instead of a value that may or may not be nil. Convention over configuration and least surprise are the tests every design choice here has to pass, and where the two idioms already agree we follow the convention and say nothing more about it.

Where the gem leaves one of them, the docs say so and say why. The reason is nearly always mechanical: ActiveRecord assumes things about accessors that a strict Option cannot satisfy. The ActiveRecord compromises are that register, enumerated and closed.

Installation

Install the gem and add to the application's Gemfile by executing:

bundle add errgonomic

If bundler is not being used to manage dependencies, install the gem by executing:

gem install errgonomic

Errgonomic requires Ruby >= 3.0.

Usage

Presence helpers

The present_or method takes what you might ordinarily write as foo || default with a possible nil or falsey value, and brings that to any other object that may be blank?.

nil.present_or("default")
# => "default"

[].present_or(["default"])
# => ["default"]

We don't have static type checking here in Ruby, so the library is also annoyingly pedantic about matching classes for the supplied default value.

[].present_or("uh-oh")
# => Type mismatch: default value is a String but original was a Array (Errgonomic::TypeMismatchError)

When constructing that fallback object may be expensive, you can provide a block instead:

[].present_or_else { ["default"] }
# => ["default"]

And when all else fails, you can control the failure, by raising an exception for blank objects. This can be preferable to sending a blank object to some other downstream code that may be expecting a value, causing an ambiguous failure.

[].present_or_raise!("foo")
# => foo (Errgonomic::NotPresentError)

Each helper has a blank_or* counterpart for when you expect the object to be blank: blank_or, blank_or_else, blank_or_raise!.

Type assertions

The same pattern applies to runtime type expectations:

"hello".type_or_raise!(String)
# => "hello"

123.type_or_raise!(String)
# => Expected String but got Integer (Errgonomic::TypeMismatchError)

123.type_or(String, "default")
# => "default"

123.type_or_else(String) { "default" }
# => "default"

"hello".not_type_or_raise!(Integer)
# => "hello"

Option

Some(value) and None() wrap a value that may or may not be there, with most of the Rust Option combinators:

Some(1).unwrap!                  # => 1
None().unwrap!                   # => raises Errgonomic::UnwrapError
None().unwrap_or(2)              # => 2
None().unwrap_or_else { 2 }      # => 2
Some(1).expect!("must be set")   # => 1

Some(1).map { |x| x + 1 }        # => Some(2)
Some(2).and_then { |x| Some(x + 1) } # => Some(3)
Some(2).map_or(0) { |x| x * 2 }  # => 4, a bare value
None().map_or(0) { |x| x * 2 }   # => 0
None().or(Some(1))               # => Some(1)
Some(:left).xor(None())          # => Some(:left)
Some(1).zip(Some(2))             # => Some([1, 2])
Some(1).ok_or("nope")            # => Ok(1)
None().ok_or("nope")             # => Err("nope")

Some(1).each { |x| log(x) }      # yields once; None() yields nothing
Some(1).each.to_a                # => [1]

each is the whole of the collection surface. An Option does not include Enumerable, because filter and first already answer Options here and Enumerable's answer plain values; opt.each hands you an Enumerator when you want the rest of them.

map wraps whatever the block returns, as Rust's does, so a block that itself returns an Option gives Some(Some(x)). and_then is the spelling for that block.

Options pattern match in the Rust shape. Some, None, Ok and Err name the variants in a pattern as well as building them, so a pattern reads as it does in Rust:

case measurement
in Some(value)
  "Measurement is #{value}"
in None
  "Measurement is not available"
end

Leave the else off: the two branches cover an Option, and a value that is not one raises NoMatchingPatternError where an else would take it quietly. When that value is a Result, the error's message refuses to print; see the case/in note below. deconstruct answers [value] for a Some and [] for a None, the one-payload shape Data.define(:value) and Rust's tuple variants share, and there is no deconstruct_keys, because a one-payload sum type has no named field. Patterns nest through the inner value's own protocol instead: in Ok(Some(value)) reaches through a Result, and in Some({ id: }) reaches into a Hash a Some wraps.

A bare Some, None, Ok or Err, without parentheses, is that name and not a value, where Rust's return None is one. It matches as the class it names in a pattern or a case/when, and refuses to stand in for a value: interpolation, to_s, join, to_json and as_json raise Errgonomic::SerializeError (bare None names a variant for a pattern, not a value; build one with parentheses), and so does handing one to an ActiveRecord attribute, a bulk write or a where. It is not a class, so is_a?, kind_of? and instance_of? raise TypeError when given the bare name (class or module required), Some.new, Some <= Errgonomic::Option::Any and Some.name raise NoMethodError, and Minitest's assert_kind_of Some, x raises the same TypeError before it can assert anything. Each has a working spelling: check the variant with x.some?, x.none?, x.ok? or x.err?, or give the fully qualified class anywhere a class or module is required, as in x.is_a?(Errgonomic::Option::Some), assert_kind_of Errgonomic::Option::Some, x, Errgonomic::Option::Some.new(1), Errgonomic::Option::Some <= Errgonomic::Option::Any or Errgonomic::Option::Some.name. Build with the constructor, Some(1), rather than .new. An application constant of the same name collides loudly: defined before errgonomic loads, it stops the load with a NameError, and a class None or module Ok written after raises TypeError where it is written. Two forms get past that. None = … assigned after the load replaces the name with only Ruby's already initialized constant warning, and in a Rails application Zeitwerk skips an autoloaded app/models/ok.rb because Ok is already defined, so the first call on Ok raises NoMethodError.

An unhandled Option refuses to leak into your output: to_s, to_json and as_json raise Errgonomic::SerializeError, so you handle the inner value deliberately rather than shipping Some("...") to a user. The refusal names what it was carrying (cannot serialize an unwrapped Some("cell-a1b2")), so a payload built out of many values says which one went unhandled; the value's inspect is bounded to 60 characters, with an ellipsis past that. The refusal covers as_json because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as {"value": ...}. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own as_json says what an unconverted record's says. See Rails integration.

The to_s refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, Array#join, format, String(), a bare ERB <%= %> and the key of a Hash on its way to JSON all reach the value through to_s, and every one of them raises rather than writing Some("...") into a hostname, a column or a page. Rust gives Option a Debug and no Display, and inspect is the Debug here: it renders Some(1), and it is what a log line or a rescue should call ("got #{opt.inspect}"). The refusal says so: Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value.

Two places reach to_s on your behalf, and read badly when it refuses. The first is a case/in with more than one in branch where none matches. Ruby raises NoMatchingPatternError with the unmatched Option or Result itself as the message, and printing that message calls to_s. Uncaught, it prints as a bare NoMatchingPatternError with no subject, and inside a Minitest test the reporter crashes on it with Some(1) refuses to_s; … before it prints the test's name or the run summary. The subject is a variant the case has no branch for, such as a Result handed to Option patterns, so the fix is the missing in branch, or an else; an inspect has nowhere to go. A single-branch case/in and a rightward => build their message with inspect and print normally.

The second is a logger. logger.info(opt) renders Some(1) through a plain Logger, whose formatter calls inspect on a message that is not a String, but raises through Rails' ActiveSupport::TaggedLogging once a tag is set, because the tagged formatter interpolates the message. Rails' generated production.rb tags every request with log_tags = [:request_id], so the same line passes in tests and raises in production. Write logger.info(opt.inspect).

expect! also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: tier.expect! { "no tier for #{account.id}" }. present_or_raise! takes one on the same terms. The positional form is unchanged.

unwrap! and expect! are for tests and consoles, not application code: they raise on None, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the None branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap.

Presence follows the discriminant, as in Rust: Some is present? and None is blank?, regardless of the wrapped value. So Some(false).present? and Some(nil).present? are both true. If you care about the inner value's own presence, unwrap it first.

Truthiness is the Rails reflex that breaks. An Option is an object, so None() is truthy: isbn || 'unassigned' hands back the None, if isbn takes the present branch, and nothing raises to say so. Reach for unwrap_or('unassigned'), or for map and and_then when the fallback is itself an Option. Safe navigation looks for the nil object rather than asking nil?, so isbn&.strip calls into the Option and raises Errgonomic::UnwrappedAccessError, where isbn.map(&:strip) does what was meant. Under the Rails integration None#nil? answers true, so an explicit nil? check behaves, but || and &. never consult it.

Writers unwrap under that integration, which changes what a truthiness slip costs rather than removing it. self.isbn = isbn || 'unassigned' no longer leaks a wrapper into the database; it silently persists whatever isbn held, nil included, because a None is truthy and the fallback is never reached. The write succeeds and nothing raises. unwrap_or('unassigned') is the spelling that means it.

presence is the Rails spelling of unwrap_or(nil), and it is supported: Some(x).presence is x and None().presence is nil, so isbn.presence || 'unassigned' reaches the value rather than the wrapper. It follows the discriminant, as every presence question on an Option does, so Some("").presence is "" where "".presence on any other object is nil. An Option's presence is whether it holds a value, not what that value amounts to; unwrap first (isbn.unwrap_or("").presence) to ask the inner value's own presence.

The remaining present-side helpers are soft-deprecated on Options in favor of the combinators. They unwrap, where on any other object they return the receiver: Some(v).present_or_raise!(msg), present_or(default) and present_or_else { } all yield v, and None raises, substitutes, or computes. Each prints a one-line stderr nudge naming the combinator to use instead (expect!, unwrap_or, unwrap_or_else), once per process per method rather than once per call, so a hot path does not flood the log. The blank side (blank_or*) raises UnwrappedAccessError outright: an Option's blankness is its discriminant, so test it with none?.

Four of Rust's methods are deliberately absent: take, replace, insert and get_or_insert. Every one of them writes through an &mut Option, and an Option here is a value rather than a slot: Some(1) is something you pass around and compare, not a cell whose contents you swap out from under another reference. Build the Option you want and assign it where the old one lived. The same rule is why a Some, an Ok and an Err have no value reader or writer, and why every instance is frozen as it is constructed. A copy that skips construction, from dup, Marshal.load, a YAML load or ActiveSupport's deep_dup, is not frozen; with no writer, it changes only through instance_variable_set. A reader would reach the inner value with no None branch, and a writer would move a Hash key out from under its own bucket. Reach in with unwrap_or, expect!, map, and_then or a pattern, each of which names what happens on the other branch.

Equality is between Options only: Some(5) == Some(5) is true and Some(5) == Some(6) is false, and comparing an Option with anything that is not one raises Errgonomic::TypeMismatchError. Rust rejects Some(5) == 5 at compile time; Ruby cannot, and a quiet false there is a silent wrong branch, the same failure as a wrapper written into a string. Compare against a wrapped value (opt == Some(5)), test the inner value (opt.some_and? { |v| v == 5 }), or unwrap_or a fallback first. None() == nil raises too, pointing at none?. See Strict equality for where the raise reaches and where it cannot.

Ordering is between Options too, and unlike equality it says so out loud. None() sorts before any Some and two Somes order by their inner values, so a collection of Options sorts. Ordering one against a bare value raises Errgonomic::TypeMismatchError naming both operands and the spellings that work: Some(read_at) <= Time.current used to answer nil from <=>, which Comparable turned into an ArgumentError naming the Option as the operand at fault. Test the inner value (read_at.some_and? { |t| t <= Time.current }) or reach for it with map or unwrap_or. Two Options whose inner values do not compare still answer nil, as Ruby expects. That message is the gem's only where the Option is the receiver: with it on the right (2 < Some(1), [Some(1), 2].max) Integer answers the comparison itself and Ruby raises its own ArgumentError: comparison of Integer with Errgonomic::Option::Some failed. Results order the same way, with ok_and? in place of some_and?.

Result

Ok(value) and Err(error) express an operation that may fail, again with the Rust combinators:

Ok(1).unwrap!                        # => 1
Err(:nope).unwrap!                   # => raises Errgonomic::UnwrapError
Err(:nope).unwrap_or(2)              # => 2

Ok(1).map { |x| x + 1 }              # => Ok(2)
Err(:bob).map_err { |e| e.capitalize } # => Err(:Bob)
Ok(1).and_then { |x| Ok(x + 1) }     # => Ok(2)
Err(:e).or_else { |e| Ok(1) }        # => Ok(1)

Ok(1).ok_and?(&:odd?)                # => true
Err(:a).err_and? { |_| true }        # => true

Results also pattern match, including against the kind of inner value:

case result
in Ok(value)
  "Measurement is #{value}"
in Err(String => msg)
  "Measurement failed with a message: #{msg}"
in Err(Exception => e)
  "Measurement produced an exception -- #{e.class}: #{e}"
end

Like Options, unwrapped Results refuse to_s, to_json and as_json, and render through inspect. And Object#result? / Object#assert_result! help enforce at runtime that a value is a Result.

Optional collections

Hash and Array gain two additive lookups each, and nothing else changes about them. fetch_option follows presence the way Rust's HashMap#get and slice get do: a present key or index holding nil is Some(nil), and only a missing one is None().

h = { color: :blue, shade: nil }
h.fetch_option(:color)  # => Some(:blue)
h.fetch_option(:shade)  # => Some(nil)
h.fetch_option(:smell)  # => None()

[:a, nil].fetch_option(1)  # => Some(nil)
[:a, nil].fetch_option(2)  # => None()

into_optional wraps the collection in Errgonomic::OptionalHash / Errgonomic::OptionalArray, a view whose lookups all return Options. The wrappers are deliberately small: [], []=, dig, presence checks, and (for arrays) first/last. They are composed around the plain collection rather than subclassing it, because a subclass sheds its custom semantics every time select or transform_values returns a plain Hash. to_h / to_a hand back a detached copy.

h = { person: { name: 'Ada', middle_name: nil } }.into_optional
h.dig(:person, :name)         # => Some("Ada")
h.dig(:person, :middle_name)  # => Some(nil)   (present, holding nil)
h.dig(:person, :nickname)     # => None()      (absent)

[].into_optional.first        # => None()

dig checks presence at every step, so an absent path and a present nil stay distinguishable, which core dig conflates. Digging into a non-collection raises Errgonomic::TypeMismatchError rather than answering None(), in the gem's pedantic style.

sequence_options and sequence_results are the all-or-nothing collection, which Rust spells as a collect into Option<Vec<T>> or Result<Vec<T>, E>. The name is Haskell's sequence, the operation Rust's collect performs underneath, rather than anything a Rubyist would already recognize. They are on Enumerable, so they compose with map instead of needing a wrapper type. The first None or Err short-circuits, and an Err comes back as it stands, still carrying its error.

[Some(1), Some(2)].sequence_options   # => Some([1, 2])
[Some(1), None()].sequence_options    # => None()
[].sequence_options                   # => Some([])

[Ok(1), Ok(2)].sequence_results       # => Ok([1, 2])
[Ok(1), Err(:nope)].sequence_results  # => Err(:nope)

A member that is not an Option, or not a Result, raises Errgonomic::TypeMismatchError in the same pedantic style as Option#flatten. It raises regardless of with_ambiguous_downstream_errors, which relaxes what a block returned rather than what a caller passed in. A Hash enumerates as pairs, which are Arrays, so hash.values.sequence_options is the spelling for a hash of Options.

Three operations over a collection of Options are easy to confuse with one another, so it is worth naming all three:

Rust meaning errgonomic
iter.flatten() drop the absent members reject(&:none?), select(&:some?), or flat_map(&:to_a) to unwrap while dropping
Option::flatten unnest an Option<Option<T>> Option#flatten
collect::<Option<Vec<_>>>() all or nothing sequence_options, sequence_results

Array#compact is not in that first row. It is implemented in C and tests for the nil object rather than asking nil?, so it keeps a None where the idiom reads as though it drops it, and something downstream then dereferences the wrapper. Use reject(&:none?) or select(&:some?) to keep the wrappers, flat_map(&:to_a) to unwrap in the same pass, and sequence_options when an absent member should take the whole collection with it. The first two ask every member the question, so a plain nil still in the list raises NoMethodError; flat_map(&:to_a) survives one, since nil.to_a is [], but not a bare value.

Booleans

Booleans lift into the containers, following Rust's bool: then_some, and ok_or/ok_or_else from nightly. Rust splits the lazy form into then, but that name is core Ruby (Kernel#then), which Errgonomic will not redefine; then_some takes either a value or a block instead. Rust's ok_or returns Result<(), E>; Ruby has no unit type, so Ok carries true.

admin.then_some(:badge)       # => Some(:badge) when true, None() when false
admin.then_some { badge! }    # lazy variant
valid.ok_or("invalid input")  # => Ok(true) / Err("invalid input")

Pedantic runtime checks

Combinators that accept a block (and_then, or_else, ...) check at runtime that the block returned an Option or Result, raising Errgonomic::ArgumentError otherwise. That beats an ambiguous undefined method error somewhere downstream. If you would rather have the ambiguous downstream errors, you can opt out, but not quietly:

Errgonomic.with_ambiguous_downstream_errors do
  # anything goes in here
end

Strict equality

Cross-type equality is the other pedantic check, and there is no switch for it. A comparison between a wrapper and a value that is not one raises Errgonomic::TypeMismatchError, naming both classes and the spelling to reach for:

Some(5) == 5        # => raises Errgonomic::TypeMismatchError
Some(5) != 5        # => raises
Some(5).eql?(5)     # => raises
Some(5) === 5       # => raises, so `case 5 when Some(5)` raises too
None() == nil       # => raises, pointing at none?
Ok(1) == 1          # => raises
Some(1) == Ok(1)    # => raises: an Option and a Result are different containers
Some(5) == Some(5)  # => true
Some(5) == None()   # => false

1 == Some(1)        # => raises, through Integer's coercion fallback
nil == None()       # => false, quietly
"a" == Some("a")    # => false, quietly

A Result is cross-type for an Option and an Option is cross-type for a Result: they are different containers, neither is the other, and the message says to unwrap whichever one you meant. Two Options, or two Results, compare by variant and inner value, and hash is untouched, so an Option is a Hash key like any other value.

The raise surface is uneven, because Ruby's collections reach equality three ways, and which side holds the wrapper decides what happens. The paragraph after this one gives the rule for == itself.

  • Pairwise ==: Array#include?, Array#index, Array#delete, Array#count, Array#==, Hash#==, case/when and Minitest's assert_equal raise when the wrapper is the one asked: a member of the collection searched ([Some(1)].include?(1)), a when clause, or assert_equal's expected value, which comes back as an error rather than a failure. With the wrapper on the other side the bare value answers. An Integer or another Numeric hands the comparison back, so [1].include?(Some(1)) raises too; a String, a Symbol or nil answers false itself, so ["a"].include?(Some("a")) and case Some("a") when "a" stay quiet, and assert_equal "a", Some("a") is an ordinary failure.
  • Pairwise eql?: Array#-, Array#& and Array#| compare member by member with eql? while the arrays are short, and a bare value's eql? never hands the comparison back. [Some(1)] - [1], [Some(1)] & [1] and [1] | [Some(1)] raise; [1] - [Some(1)], [1] & [Some(1)] and [Some(1)] | [1] stay quiet. Past Ruby's cutoff of 16 members they hash instead and stay quiet: - once both arrays are past it, & and | once either is.
  • Hashing: Hash#[], Set#include?, uniq and group_by compare hash values first and ask eql? only of a candidate whose hash matches, so they stay quiet whichever side holds the wrapper.

Strict equality never answers wrong; it only sometimes fails to catch.

Strictness fires when the wrapper is the receiver, and also when the left operand hands the comparison over: 1 == Some(1) raises because Integer#== falls back to asking the right-hand side. nil == Some(1), nil == None() and "a" == Some("a") stay quietly false, because NilClass and String answer for themselves and never consult the operand, and nothing in the gem can intercept them. Put the wrapper on the left in a test if you want the check to reach every comparison.

Rails integration

When Rails::Railtie is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:

  • include Errgonomic::Rails::ActiveRecordOptional in a model makes its nullable attributes and optional: true associations return Some(value) or None() instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Four kinds of reader stay unwrapped: a reader a framework macro declares and then reads for itself, which is the associations has_rich_text and has_one_attached declare and the digest column has_secure_password hands to BCrypt; a singular association with accepts_nested_attributes_for, which ActiveRecord assigns through the reader and reads raw; a has_one ..., required: true, whose absence is a validation failure rather than a value; and anything named by errgonomic_optional_except.
class Credential < ApplicationRecord
  errgonomic_optional_except :legacy_token
  include Errgonomic::Rails::ActiveRecordOptional

  encrypts :access_secret   # wrapped like any other nullable column
  has_one :rotation_schedule # wrapped: Some(schedule) or None()
  has_one :owner, required: true # left unwrapped: absence is a validation failure
  has_secure_password # left unwrapped: BCrypt reads password_digest raw
end

The framework exclusions need no declaration and hold wherever the macro is written. has_rich_text and has_one_attached are recognized by the class their associations name, so neither engine has to be loaded for a model to be asked; has_secure_password by the module it includes for the attribute, so has_secure_password :recovery_password excludes recovery_password_digest as well.

Where the include goes. A model that includes the concern converts itself, and only itself. The include may sit at the top of the model with the other concerns, which is where Rails convention puts one. An optional: true association declared below it is wrapped as it is declared, rather than only the associations the class happened to declare above it.

class Book < ApplicationRecord
  include Errgonomic::Rails::ActiveRecordOptional

  belongs_to :author, optional: true   # Some(author) or None()
end

On an application's own base class, the same include reaches every model below it, and no model mentions errgonomic again:

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  include Errgonomic::Rails::ActiveRecordOptional
end

Converting one model or all of them is therefore where the include goes, not a setting to choose. The association macros wrap as each model declares them, and a model's nullable columns are wrapped when ActiveRecord loads its schema, so no class body needs a database while it loads.

An application's own base class is the useful place for it. Engine and gem models such as ActiveStorage::Blob and PaperTrail::Version descend straight from ActiveRecord::Base, and their own code reads their attributes knowing nothing about an Option. Including it on ActiveRecord::Base reaches those too, which is rarely what anyone wants.

Two ways out, both readable in a model with no include of its own to point at:

class LegacyImport < ApplicationRecord
  errgonomic_optional_off                     # this model keeps value-or-nil throughout
end

class Credential < ApplicationRecord
  errgonomic_optional_except :legacy_token    # this attribute does
end

Overriding a wrapped reader. Wrapped readers live in a module the concern includes into the model, so a model's own def of the same name coexists with the wrapper and reads the Option through super. The rule is one of layering: a def in the model's own class body, or a module the model itself includes after the errgonomic include, sits above the wrapper and reads the Option from super.

The type does not change inside the override. super hands back exactly what every other caller of the reader gets, so an override that keeps the Option keeps the model's contract:

class Book < ApplicationRecord
  include Errgonomic::Rails::ActiveRecordOptional

  belongs_to :author, optional: true

  def isbn
    super.map(&:strip)   # super is Some(isbn) or None(), and so is this
  end

  def display_isbn
    isbn.unwrap_or('unassigned')
  end
end

An accessor that hands back a plain value is a different method with a different name, the way a Rust fn display_name(&self) -> String sits beside a name: Option<String> field. display_isbn is that method; isbn stays the field.

A same-named def that never calls super is legal Ruby and the model owns its return value outright: the wrapper stays installed beneath it and nothing reaches it. It is the un-idiomatic spelling, and it leaves one loose end: Model.errgonomic_optionals still reports the reader as wrapped, because the conversion did wrap it.

Storage stays nullable; the reader is the boundary. Only the reader returns an Option. self[:isbn], read_attribute(:isbn), isbn_was, isbn_change, and attributes all answer the raw column value or nil, which is where Rails already draws the line for a reader override: the attribute is the storage, the reader is the interface. Rust would expect the Option all the way down, and this is the largest place the gem does not follow it, because dirty tracking, serialization, and query building each read the attribute directly and an Option would have to survive all of them.

Writers take either a plain value or an Option of one, for attributes and singular associations alike, so a wrapped reader's value assigns straight back: other_book.title = book.title and other_book.author = book.author both do what they read like. book.isbn = '9780765377104' means what it always did, and assigning None() stores nil. Assigning an Option is assigning the value inside it for every column type, so a wrapped false stores false, and the storage behind the reader stays raw: changes, read_attribute_before_type_cast and attributes see the value, never the wrapper. find, find_by, exists?, update_all, insert_all, upsert and an attribute default take Options on the same terms, unwrapping where the value enters ActiveRecord rather than at a writer.

Every one of those seams is installed on ActiveModel or ActiveRecord itself, as the quoting and predicate-builder seams are. They apply to every model in the application, whether or not it includes the concern: the concern decides what a reader returns, not what a writer accepts. None of them asks anything of the column type, so a type that never calls super from its own cast or serialize needs no cooperation: an application's own ActiveModel::Type::Value subclass and a json column both take an Option wherever a plain value goes.

Validation reads the value. Standard validators on a converted model behave exactly as they do on an unconverted one. inclusion, exclusion, presence, length, format, numericality and the rest weigh the value inside the Option, and a None validates like nil, because every EachValidator fetches its attribute through read_attribute_for_validation, which unwraps. validates :isbn, some: true is the Option-aware presence check, asking only whether the value is there: Some('') passes some: and fails presence: true, exactly as '' fails it. Custom validation code is the exception, because it reads the public reader: a validate :check_isbn whose body calls isbn gets Some('9780765377104'), the same as every other caller.

Form helpers render the value. A form built on a converted model renders what a form on an unconverted one renders. form_with, form_for and the field helpers read the record through ActionView::Helpers::Tags::Base#value whenever the value did not come from user input, which is every record an edit form loads from the database, and that seam unwraps. So text_field writes the value into the markup, check_box reads a wrapped false as unchecked rather than raising on to_i, and datetime_field formats the time inside the Some rather than raising on strftime. A None renders an empty field, exactly as nil does. Only the reader path goes through that seam, so an association object handed to a helper explicitly is the caller's own value: fields_for :award, author.award passes the Some, and wants author.award.unwrap_or(nil) or the fields_for :award form, which reads the association itself.

Serialization. A converted model serializes as the unconverted one does. as_json, to_json and serializable_hash fetch every attribute through read_attribute_for_serialization, which unwraps, so Some(v) writes v and None() writes null. Both idioms agree on the default: Rails writes an absent value as null, and so does serde unless a field asks otherwise. The refusal stands everywhere else, so a hand-built Option in an arbitrary payload ({ isbn: book.isbn }.to_json) still raises Errgonomic::SerializeError.

An association under include: follows the same rule: Some(author) serializes as the record's own hash, and a None leaves the key out, which is what include: already does with a nil association. A has_many is never an Option and is untouched. A wrapped reader named in methods: unwraps one layer as well, so as_json(methods: :isbn) writes the value; a method that hands back a plain value is unchanged.

Omission is the opt-in, as it is in serde, and it is declared on the model rather than on belongs_to or has_one:

class ApplicationRecord < ActiveRecord::Base
  include Errgonomic::Rails::ActiveRecordOptional
  errgonomic_serialize_none :omit                  # drop keys whose value is None
end

class Book < ApplicationRecord
  errgonomic_serialize_none :null                  # this model keeps them, as null (the default)
end

class Manuscript < ApplicationRecord
  errgonomic_serialize_none :omit, only: %i[isbn]  # only this reader is dropped; except: also accepted
end

The declaration reads as well above the include as below it, as errgonomic_optional_except does. The nearest declaration wins and replaces whatever it inherits, rather than layering onto it, so a reader a scoped declaration does not name keeps the default. Omission drops keys from the payload the caller asked for, so it composes with the caller's own only: and except:. It governs by reader name wherever the key came from, so a methods: entry naming a wrapped reader that reads None is dropped along with the reader, while a plain method that happens to return nil is kept. A declaration that cannot change a payload raises ArgumentError where it is written, naming what to write instead. That covers a mode other than :null or :omit, only: together with except:, and a scoped :null, which asks for the default on the readers it names and leaves the rest at the default anyway.

Model.errgonomic_optionals reports which readers a model wrapped, including nullable foreign-key columns, so book.author_id is Some(1) alongside book.author. That is how to check that a conversion did what it meant to. A subclass reports what it inherited alongside anything it wrapped itself, so the report names every wrapped reader the class responds to; Model.errgonomic_optional_names is the set that class wrapped on its own.

delegate_optional is Rails' delegate with allow_nil, where the absent case is a None rather than a nil: delegate_optional :name, to: :author gives book.name # => Some('Cixin Liu'), and None() where there is no author. The prefix forms are Rails': prefix: true names the reader after the target (author_name), and prefix: :writer names it writer_name. private: true works as it does there. The reader forwards whatever it was called with, arguments and block alike. allow_nil: true is accepted and says nothing new, so a delegate declaration swaps over unchanged unless it delegates a writer or a predicate. A name ending in ? asks for a verdict, and no Option can serve as one, because every Option is truthy. So a predicate answers a bare true or false through some_and?: delegate_optional :prolific?, to: :author gives book.prolific? # => false for an unprolific author, and false again where there is no author, which branches an if the way Rails' allow_nil: true does. The one thing that costs is a ? method that answers a value by design, which now answers true; declare it without the ?, or write some_and? by hand. delegate_optional :name=, to: :author raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. allow_nil: false asks for a reader that raises on absence, which this does not have, so it raises ArgumentError where it is written, as a declaration with no to: does.

It is available on every model, converted or not, because it lifts both ends one layer. The target is lifted, so a plain record reads as Some and a nil as None. What the delegated call returns is lifted too, so a delegated reader that is itself an Option comes back as one Option rather than two.

Object#to_option lifts any value into an Option (nil.to_option # => None()). It lifts once and only once: an Option passes through unchanged (Some(1).to_option # => Some(1)), so lifting a value whose provenance you do not know is safe. That is the rule everywhere in the integration. An ActiveRecord attribute or association is never an optional of an optional, so a wrapped reader never nests a second Option around a value that already is one. Nesting is invisible until something reaches for the inner value, which is the ambiguous failure the type exists to prevent.

try reaches the value inside the Option: book.isbn.try(:strip) strips the ISBN and answers nil where there is none, and the block form yields the value (book.isbn.try { |isbn| isbn.strip }). ActiveSupport's Object#try asks respond_to? first, which an Option answers false to for anything it does not define, so without this it would be a quiet nil for every method and would hand a block the wrapper rather than the value. A method the value does not have is still nil, as it is for any other receiver, and try! is Rails' strict variant, which raises for that and still answers nil for a None. Both are defined only under this integration, where ActiveSupport's try is what they follow.

ActiveRecord compromises

This is the register of where the gem leaves the Rust idiom, and why. ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries five deliberate compromises, each one forced by a specific piece of ActiveRecord machinery rather than chosen. Everywhere else, treat a departure from Rust's Option semantics as a bug; these five are intended:

  1. None#nil? answers true, so ActiveRecord internals and ordinary .nil? checks treat an absent value as absent. Equality does not follow suit: None() == nil raises Errgonomic::TypeMismatchError, pointing at none?, and nil == None() is false, answered by NilClass. Nor does Array#compact, the common collection idiom for dropping absent members: it tests for the nil object, so it keeps a None where reject(&:none?) drops it.
  2. Some delegates persisted? and touch_later to its record, so a Some can stand in for it where ActiveRecord reads an association back through its public reader, as a belongs_to ..., touch: true does after a save.
  3. An Option is unwrapped where a value enters ActiveRecord, above the column type in every case. Quoting and the predicate builder are patched so an Option passed into where/quote is unwrapped at the SQL boundary: Some(v) binds exactly as v, and None() as nil, so a hash condition asks for IS NULL. An array of Options unwraps too. An Option interpolated into raw SQL (where("id = ?", opt)) still raises, as it should. Assignment unwraps on the same principle. A singular association writer takes an Option of a record: book.author = Some(author) assigns it and book.author = None() clears the association, while a Some of the wrong class still raises AssociationTypeMismatch. An attribute writer takes an Option of a value, for every column type, and unwraps before the attribute is built, so book.isbn = other.isbn round-trips and nothing behind the reader ever holds a wrapper. A value that reaches the database without passing a writer unwraps where it enters ActiveRecord, above the column type in every case: in the ids and conditions find and find_by are given, on a class, a relation and an association alike; in the rows update_all, insert_all and upsert take; and in a default declared with attribute :isbn, :string, default: Some('unassigned'), unwrapped where it is written.
  4. SomeValidator asks whether a value is there at all, where presence asks whether it amounts to anything: Some('') passes validates :x, some: true and fails presence: true. It lifts what it is handed, so it asks the same question of any model, converted or not.
  5. Where the framework's own machinery reads a value raw, it gets one. Validation unwraps at read_attribute_for_validation, the seam every EachValidator fetches an attribute through; serialization at read_attribute_for_serialization, the seam every attribute in a payload is fetched through; and a form helper at ActionView::Helpers::Tags::Base#value, the seam every field reads its record through. So a standard validator weighs the value, a payload carries it and a form renders it, rather than the wrapper. A singular association with accepts_nested_attributes_for goes further and keeps its plain reader: nested attributes are assigned through the reader, and ActiveRecord asks whatever it finds there whether it is a new record. So does a reader a framework macro declares and then reads for itself, which is the associations behind has_rich_text and has_one_attached and the digest column has_secure_password hands to BCrypt.

The set is closed. If a future integration appears to need a sixth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. errgonomic_optional_except and errgonomic_serialize_none are deliberately not on the list: they are configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next and a choice of how an absent value is written, rather than semantic exceptions.

Known limitations

A converted model with belongs_to :writer, optional: true, touch: true raises Errgonomic::UnwrappedAccessError (undefined method `persisted?' for None) when it saves or is destroyed with the association absent: on create, on any update, and on destroy. ActiveRecord's touch callback reads the association back through the public reader and asks record && record.persisted?, and a None is truthy and does not delegate persisted? the way a Some does. Leave the association unwrapped with errgonomic_optional_except :writer, which keeps touch: true working and hands back nil for an absent record.

Development

After checking out the repo, run bin/setup to install dependencies. You can also run bin/console for an interactive prompt that will allow you to experiment. The repository is a self-contained Nix flake; with direnv, direnv allow puts the right toolchain on your path.

This project encourages red, green, refactor when making changes. First, add or change a test that captures the desired behavior; next, run the tests to observe the failure message, confirming the test is useful; next, make the smallest code change(s) to make the test pass. Once tests pass, review your diff and look for opportunities to simplify or improve abstractions; make changes and iterate, running tests on each change to guard against regressions.

Most of the behavior above is specified as YARD doctests, so the examples in the code documentation are the test suite. Run them with:

nix develop -c rake yard:doctest

Run the full suite (unit tests plus doctests) with:

nix develop -c rake

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/omc/errgonomic. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Errgonomic project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

About

Ergonomic error handling in Ruby

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages