Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
require: rubocop-yard
plugins: rubocop-yard

Naming/MethodName:
AllowedPatterns:
- '\AOk\Z'
- '\AErr\Z'
- '\ASome\Z'
- '\ANone\Z'

Metrics/ClassLength:
Max: 150

Lint/MissingSuper:
AllowedParentClasses:
- Any
4 changes: 2 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ source 'https://rubygems.org'
gemspec

group :development do
gem 'activerecord'
gem 'minitest'
gem 'rake', '~> 13.0'
gem 'rspec', '~> 3.0'
gem 'rubocop'
gem 'rubocop-yard'
gem 'solargraph'
gem 'activerecord'
gem 'sqlite3'
gem 'minitest'
end

# gem "standard", "~> 1.3"
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
errgonomic (0.4.0)
errgonomic (0.5.0)
concurrent-ruby (~> 1.0)

GEM
Expand Down
2 changes: 1 addition & 1 deletion errgonomic.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
spec.email = ['nick@onemorecloud.com']

spec.summary = 'Opinionated, ergonomic error handling for Ruby, inspired by Rails and Rust.'
spec.description = "Let's blend the Rails 'present' and 'blank' conventions with a few patterns from Rust Option types."
spec.description = "Let's blend Rails presence conventions and the Rust Option and Result types."
spec.homepage = 'https://omc.io/'
spec.license = 'MIT'
spec.required_ruby_version = '>= 3.0.0'
Expand Down
4 changes: 4 additions & 0 deletions lib/errgonomic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
# Rails fu
require_relative 'errgonomic/rails' if defined?(Rails::Railtie)

# Optional enumerables
require_relative 'errgonomic/enumerable'

# Errgonomic adds opinionated abstractions to handle errors in a way that blends
# Rust and Ruby ergonomics. This library leans on Rails conventions for some
# presence-related methods; when in doubt, make those feel like Rails. It also
Expand All @@ -27,6 +30,7 @@ class NotPresentError < Error; end

class TypeMismatchError < Error; end

# Unwrapping a None or Err raises an exception
class UnwrapError < Error
def initialize(msg, value)
super(msg)
Expand Down
8 changes: 8 additions & 0 deletions lib/errgonomic/core_ext/blank.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'concurrent/map'

# If we're not already working with blank?, et al, then add them.
class Object
# An object is blank if it's false, empty, or a whitespace string.
# For example, +nil+, '', ' ', [], {}, and +false+ are all blank.
Expand Down Expand Up @@ -47,6 +48,7 @@ def presence
end
end

# If we're not working with blank?, et al, then add them.
class NilClass
# +nil+ is blank:
#
Expand All @@ -62,6 +64,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class FalseClass
# +false+ is blank:
#
Expand All @@ -77,6 +80,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class TrueClass
# +true+ is not blank:
#
Expand All @@ -92,6 +96,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class Array
# An array is blank if it's empty:
#
Expand All @@ -106,6 +111,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class Hash
# A hash is blank if it's empty:
#
Expand All @@ -120,6 +126,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class Symbol
# A Symbol is blank if it's empty:
#
Expand All @@ -132,6 +139,7 @@ def present? # :nodoc:
end
end

# If we're not working with blank?, et al, then add them.
class String
BLANK_RE = /\A[[:space:]]*\z/
ENCODED_BLANKS = Concurrent::Map.new do |h, enc|
Expand Down
14 changes: 14 additions & 0 deletions lib/errgonomic/enumerable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require_relative './enumerable/optional_hash'

module Errgonomic
# Extend various Enumerable classes to return Option, like Hash
module Enumerable
end
end

module Enumerable
class Hash
end
end
55 changes: 55 additions & 0 deletions lib/errgonomic/enumerable/optional_hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require_relative '../option'

module Errgonomic
module Enumerable
# Optional Hash
#
# Implements a hash where fetching returns an +Option+ instead of nil. We
# focus on the #[] operator and #dig function, because #fetch will raise
# KeyError when the key is not present.
#
class OptionalHash < ::Hash
# Retrieve a value with a given key, wrapping the value in an Option. The
# +nil+ value is replaced with a +None+ and anything else is wrapped in
# +Some+.
#
# @example
# h = Errgonomic::Enumerable::OptionalHash.new
# h[:color] = :blue
# h[:color] #=> Some(:blue)
# h[:butwhy] = nil
# h[:butwhy] # => Some(nil)
def [](key)
return None() unless key?(key)

Some(super(key))
end

# Similar to +Hash#dig+ but wrapped in an Option.
#
# @example
# h = Errgonomic::Enumerable::OptionalHash.new
# h[:description] = Errgonomic::Enumerable::OptionalHash.new
# h[:description].unwrap![:short] = "It's a thing"
# h.dig(:description, :short) # => Some("It's a thing")
# h.dig(:description, :long) # => None()
#
# @example
# h = Errgonomic::Enumerable::OptionalHash.new
# h[:description] = { short: { text: "Nested hash" } }
# h.dig(:description, :short, :text) # => Some("Nested hash")
# h.dig(:description, :long) # => None()
def dig(*args)
Copy link
Member Author

@nz nz Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation is probably a disaster, I am interested in something properly recursive based on key checks. Although dig semantics will certainly blur the difference between nil as value and nil as non-existent value. So I'm torn.

Copy link
Member

@h3h h3h Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that probably requires defining our expectations for dig's semantics.

I would say: “traverse this nested set of keys and return the value at the final nested key, if it exists.”

In which case, the Optional treatment could break down to something like:

  • Until the given chain (ordered list) of keys are exhausted:
    • Try to access the current nested hash at the next nested key
      • If the key exists, and the value at the key is a Hash, and there are further keys in the chain, recurse on the value
      • If the key exists and there are no more keys in the chain, return Some(value)
      • Else return None()

obj = super(*args)

return None() if obj.nil? || obj.is_a?(Errgonomic::Option::None)

obj = obj.unwrap! while obj.is_a?(Errgonomic::Option::Some)

Some(obj)
end
end
end
end
3 changes: 2 additions & 1 deletion lib/errgonomic/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ class Some < Any
attr_accessor :value

def initialize(value)
@value = value
self.value = value
end

def some?
Expand All @@ -348,6 +348,7 @@ def none?
end
end

# Represent nonexistence of a value
class None < Any
def some?
false
Expand Down
7 changes: 5 additions & 2 deletions lib/errgonomic/presence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# gem a dependency.
require_relative './core_ext/blank' unless Object.methods.include?(:blank?)

# Big noisy exceptions based on object presence. Helpful for tests or anywhere
# else one may want to set strong assertions on expected constraints. Like a
# very basic implementation of Option with more Rails-y naming conventions.
class Object
# Returns the receiver if it is present, otherwise raises a NotPresentError.
# This method is useful to enforce strong expectations, where it is preferable
Expand All @@ -18,7 +21,7 @@ def present_or_raise!(message)
self
end

alias_method :present_or_raise, :present_or_raise!
alias present_or_raise present_or_raise!

# Returns the receiver if it is present, otherwise returns the given value. If
# constructing the default value is expensive, consider using
Expand Down Expand Up @@ -61,7 +64,7 @@ def blank_or_raise!(message)
self
end

alias_method :blank_or_raise, :blank_or_raise!
alias blank_or_raise blank_or_raise!

# Returns the receiver if it is blank, otherwise returns the given value.
#
Expand Down
2 changes: 2 additions & 0 deletions lib/errgonomic/rails.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require_relative 'option'
require_relative 'rails/active_record_optional'
require_relative 'rails/active_record_delegate_optional'
Expand Down
7 changes: 6 additions & 1 deletion lib/errgonomic/rails/active_record_delegate_optional.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# frozen_string_literal: true

module Errgonomic
module Rails
# A Rails Concern to introduce the +delegate_optional+ class method helper
module ActiveRecordDelegateOptional
extend ActiveSupport::Concern

class_methods do
def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
# Like delegate, but for an optional attribute, using the Option #map
# method to return a Some or None as appropriate.
def delegate_optional(*methods, to: nil, prefix: nil, _private: nil)
return if to.nil?

methods.each do |method_name|
Expand Down
15 changes: 14 additions & 1 deletion lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,28 @@ def validate_each(record, attribute, value)

module Errgonomic
module Option
# Within the Rails context we want to treat present? and blank? as if they are some? and none?
class Any
alias some? present?
alias none? blank?
end

# A few very specific methods that Rails will invoke against the Some return
# value of an attribute. I would prefer not to have to set these, but my
# preference to avoid monkey-patching even deeper parts of Rails is
# stronger.
class Some
delegate :marked_for_destruction?, to: :value
delegate :persisted?, to: :value
delegate :touch_later, to: :value

def to_s
raise "Attempted to convert Some to String, please use Option API to safely work with internal value -- #{value}"
raise 'Attempted to convert Some to String, ' \
"please use Option API to safely work with internal value -- #{value}"
end
end

# Let Rails pretend that a None is nil. This is a monkeypatch level hack.
class None
def nil?
true
Expand All @@ -71,6 +78,10 @@ def to_s
end
end

# Automatically unwrap for ActiveRecord type_cast so it can convert values into
# Ruby data types for database stuff. This is another monkeypatch to get out of
# the way of ActiveRecord assumptions and avoid patching deeper bits of
# functionality.
module ActiveRecordOptionShim
def type_cast(value)
case value
Expand All @@ -86,12 +97,14 @@ def type_cast(value)

ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim)

# Support a generic +to_option+ method
class NilClass
def to_option
None()
end
end

# Support a generic +to_option+ method
class Object
def to_option
Some(self)
Expand Down
1 change: 1 addition & 0 deletions lib/errgonomic/result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def err?
end
end

# The error side of a result
class Err < Any
class Arbitrary; end

Expand Down
8 changes: 5 additions & 3 deletions lib/errgonomic/type.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

# Similar to 'errgonomic/presence' - very noisy assertions based on an object's
# underlying type. Useful for enforcing constraints in tests.
class Object
# Returns the receiver if it matches the expected type, otherwise raises a TypeMismatchError.
# This is useful for enforcing type expectations in method arguments.
Expand Down Expand Up @@ -56,10 +58,10 @@ def type_or_else(type, &block)
# @return [Object] The receiver if it is not of the specified type.
# @example
# 'hello'.not_type_or_raise!(Integer) #=> "hello"
# 123.not_type_or_raise!(Integer, "We dont want an integer!") #=> raise Errgonomic::TypeMismatchError, "We dont want an integer!"
# 123.not_type_or_raise!(Integer) #=> raise Errgonomic::TypeMismatchError, "Expected anything but Integer but got Integer"
# 123.not_type_or_raise!(Integer, "No gracias!") #=> raise Errgonomic::TypeMismatchError, "No gracias!"
# 123.not_type_or_raise!(Integer) #=> raise Errgonomic::TypeMismatchError, "Expected object not to be Integer"
def not_type_or_raise!(type, message = nil)
message ||= "Expected anything but #{type} but got #{self.class}"
message ||= "Expected object not to be #{type}"
raise Errgonomic::TypeMismatchError, message if is_a?(type)

self
Expand Down
2 changes: 1 addition & 1 deletion lib/errgonomic/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Errgonomic
VERSION = '0.4.0'
VERSION = '0.5.0'
end
2 changes: 2 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require 'active_record'
require 'minitest/autorun'
require 'logger'
Expand Down