Skip to content

Optional collections: OptionalHash, OptionalArray, and additive lookups - #38

Merged
nz merged 4 commits into
mainfrom
feat/optional-hash
Aug 7, 2026
Merged

nz merged 4 commits into
mainfrom
feat/optional-hash

Conversation

@nz

@nz nz commented Aug 7, 2026

Copy link
Copy Markdown
Member

Supersedes #6, keeping its idea (Option-returning hash lookups, with the explicit-key-check semantics from its second commit) and replacing the vehicle. The both-and shape agreed in review:

Additive core extensions, nothing else touched

Hash#fetch_option / Array#fetch_option return an Option following presence, exactly as Rust's HashMap::get and slice get do: present-but-nil is Some(nil), only absent is None(). Hash#into_optional / Array#into_optional wrap the collection in the view classes (precedent: Hash#with_indifferent_access is itself an additive core patch). No existing Hash/Array behavior changes; verified the names collide with nothing in ActiveSupport 8.1 (Hash has only to_options/extractable_options? in that namespace).

Composition, not subclassing

Errgonomic::OptionalHash and Errgonomic::OptionalArray wrap a plain collection with a deliberately small, closed API: [], []=, dig, key?/bounds, empty?, size, to_h/to_a (detached copies), equality satisfying the eql?/hash contract (#30), readable inspect (#21), and first/last as Options on the array (Rust's slice first/last).

Why not < Hash, verified against Ruby 3.4 while reviewing #6:

  • select/transform_values on a Hash subclass return plain Hash, so Option semantics silently shed in any pipeline. Doing this properly is the HWIA approach: override the entire ~40-method surface.
  • Hash#dig bypasses an overridden [] at the C level, so OptionalHash and a bunch of Rubocop cleanup #6's dig never saw the wrapped values its unwrap-loop expected.
  • OptionalHash and a bunch of Rubocop cleanup #6's [] and dig disagreed about stored nil (Some(nil) vs None()). Here one shared walk (OptionalDig) gives both classes the same semantics, and dig distinguishes absent path (None) from present nil (Some(nil)) — the one thing core dig cannot express.

Digging into a non-collection raises TypeMismatchError in the gem's pedantic style, where core dig raises TypeError.

Specified as doctests (23 new); README gains an Optional collections section.

nz added 3 commits August 7, 2026 13:51
Lookups follow key presence like Rust's HashMap#get: a present key
holding nil is Some(nil), only a missing key is None. dig checks
presence at every step, keeping absent paths distinct from present
nils, and raises TypeMismatchError on non-collections where core dig
would TypeError.

Composition rather than subclassing: since Ruby 3, most Hash methods
return plain Hash instances, so a subclass sheds Option semantics in
any pipeline (select, transform_values). A small closed API cannot
drift; to_h hands back a detached copy.

Hash itself gains only two additive methods, fetch_option and
into_optional; the ecosystem leans too heavily on Hash semantics to
change any existing behavior.
fetch_option follows element presence like Rust's slice get, with
bounds checked and Some(nil) for present nil elements. The
OptionalArray view adds first/last as Options, mirroring Rust's
slice first/last. Shares the presence-checking dig walk with
OptionalHash via OptionalDig.
Comment thread lib/errgonomic/optional_dig.rb Resolved
Comment on lines +81 to +83
def key?(key)
@hash.key?(key)
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Probably a small nit, but I’d use delegate for these. (Is that a stdlib thing yet?)

@nz nz Aug 7, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No idea! And if it is then we have to adopt some kind of minimum supported ruby version convention here. ¯\_(ツ)_/¯

Converting a nested OptionalHash/OptionalArray back to its plain
collection allocated a throwaway shallow dup per wrapper and
re-implemented semantics the wrapper already owns. Delegate the
remaining keys to the nested wrapper's own dig instead; a doctest
pins the nested-wrapper path.
@nz
nz merged commit 1a097c8 into main Aug 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants