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.
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)
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")
Options support pattern matching:
case measurement
in Errgonomic::Option::Some, value
"Measurement is #{value}"
in Errgonomic::Option::None
"Measurement is not available"
end
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 #<Errgonomic::Option::Some...> to a user. 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": ...}.
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.
The presence helpers are soft-deprecated on Options in favor of the combinators. The present side unwraps, where on any other object it returns the receiver — Some(v).present_or_raise!(msg), present_or(default), present_or_else { }, and presence all yield v, and None raises, substitutes, or answers nil — and each call prints a one-line stderr nudge naming the combinator to use instead (expect!, unwrap_or, unwrap_or_else, unwrap_or(nil)). The blank side (blank_or*) raises UnwrappedAccessError outright: an Option's blankness is its discriminant, so test it with none?.
Equality is between Options only: Some(5) == Some(5), but Some(5) == 5 and None() == nil are false. That is quiet, never an error, matching how every Ruby object compares across types. Rust rejects Some(5) == 5 at compile time; Ruby cannot, so guard the idiom in review and tests: compare against a wrapped value (opt == Some(5)) or test the inner value (opt.some_and? { |v| v == 5 }).
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 Errgonomic::Result::Ok, value
"Measurement is #{value}"
in Errgonomic::Result::Err, String => msg
"Measurement failed with a message: #{msg}"
in Errgonomic::Result::Err, Exception => e
"Measurement produced an exception -- #{e.class}: #{e}"
end
Like Options, unwrapped Results refuse to_s, to_json, and as_json. 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 — and 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.
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
Rails integration
When Rails::Railtie is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord:
include Errgonomic::Rails::ActiveRecordOptionalin a model makes its nullable attributes andoptional: trueassociations returnSome(value)orNone()instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Three kinds of reader stay unwrapped: attributes declared withencryptsand singular associations withaccepts_nested_attributes_for, both of which ActiveRecord's own machinery reads raw, and anything named byerrgonomic_optional_except.
class Credential < ApplicationRecord
errgonomic_optional_except :legacy_token
include Errgonomic::Rails::ActiveRecordOptional
encrypts :access_secret # also left unwrapped, declared either side of the include
has_one :rotation_schedule # wrapped: Some(schedule) or None()
has_one :owner, required: true # left unwrapped: absence is a validation failure
end
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
Model.errgonomic_optionals reports which readers a model wrapped, which is how to check that a conversion did what it meant to.
delegate_optional :name, to: :association(available on all models) delegates through an optional association, returning an Option instead of raising on nil.
Object#to_option is also available in Rails to lift any value into an Option (nil.to_option # => None()).
ActiveRecord compromises
ActiveRecord assumes things about accessors that a strict Rust Option cannot satisfy, so the integration carries five deliberate compromises. Everywhere else, treat a departure from Rust's Option semantics as a bug; these five are intended:
None#nil?answerstrue, so ActiveRecord internals and ordinary.nil?checks treat an absent value as absent. Equality does not follow suit:None() == nilis stillfalse.Somedelegatespersisted?,marked_for_destruction?, andtouch_laterto its record, so aSomecan stand in for its record during persistence.- Quoting and the predicate builder are patched so an
Optionpassed intowhere/quoteis unwrapped at the SQL boundary:Some(v)binds exactly asv, andNone()asnil, so a hash condition asks forIS NULL. An array of Options unwraps too. An Option interpolated into raw SQL (where("id = ?", opt)) still raises, as it should. SomeValidatorprovides a presence-style validation for Option attributes.- Attributes declared with
encryptsare never wrapped: ActiveRecord Encryption's own machinery (a length validator it registers outsideModel.validators) reads the raw value and cannot survive an Option.
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 is deliberately not on the list: it is configuration, an escape hatch that softens the all-or-nothing include for whatever conflict shows up next, rather than a semantic exception.
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.