Skip to content

Refuse bare operands, remove the value slot, deconstruct in the Rust shape and always raise on cross-type equality, releasing 0.10.0 - #82

Closed
nz wants to merge 11 commits into
2026-09-10-errgonomic-0.9.x-triage/to-s-raisefrom
2026-09-10-errgonomic-0.9.x-triage/core-0.10.0
Closed

nz wants to merge 11 commits into
2026-09-10-errgonomic-0.9.x-triage/to-s-raisefrom
2026-09-10-errgonomic-0.9.x-triage/core-0.10.0

Conversation

@nz

@nz nz commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Errgonomic 0.10.0 closes the four container-level issues the Sprout mass conversion filed against 0.9.0. They are a bare operand escaping Option#and, a public value slot on Some, Ok and Err, a deconstruct shape that missed in Some(v), and a cross-type == that answered false behind an opt-in flag. It stacks on the 0.9.1 branch, so this pull request is opened against 2026-09-10-errgonomic-0.9.x-triage/to-s-raise and merges after it. One choice below is the reviewer's to confirm: the top-level constants Some, None, Ok and Err, which the decision note marked as strikeable.

Closes #76, #77, #78, #79.

What changes

  • Option#and, #xor, #zip and #zip_with raise Errgonomic::ArgumentError on a bare operand, on a None receiver as well as a Some, with the message or already gave. 0.9.x let Some(2).and(3) return 3, let a None receiver accept anything, and let zip and xor fall into a bare NoMethodError.
  • Some, Ok and Err no longer expose value or value=, and every Option and Result is frozen on construction. A call from outside reaches method_missing and gets the combinator teaching as Errgonomic::UnwrappedAccessError.
  • deconstruct answers [value] for Some, Ok and Err and [] for None, so in Some(v), in None, in Ok(Some(v)) and in Some({ id: }) all match, and a two-branch case/in with no else raises NoMatchingPatternError on a wrong type. A value-less Err() deconstructs to [], so in Err and in Err() match it and in Err(e) matches only an Err that carries a value. There is no deconstruct_keys. Some, None, Ok and Err are also top-level constants for the four classes.
  • ==, !=, eql? and === between a wrapper and anything that is not one always raise Errgonomic::TypeMismatchError with the 0.9.0 message. Errgonomic.strict_equality=, strict_equality?, with_strict_equality and rake test:strict are removed.
  • Version 0.10.0, with a changelog section whose upgrading note names each removed method and its replacement spelling.

How it works

  • lib/errgonomic/option.rb: option_operand! runs before the discriminant in the five binary combinators; value is a protected reader on Some and the constructor freezes; deconstruct is to_a; strict_equality! no longer consults a flag, and === is defined so the message names the operator written. The doctests on == state the raise surface: which collection operations compare pairwise and raise, which go through a hash and stay quiet, and that nil == Some(1) is answered by NilClass.
  • lib/errgonomic/result.rb: the same shape for Ok and Err, with the protected reader on Result::Any.
  • lib/errgonomic.rb: the strict-equality flag and its block form are gone, and so is the unread lenient_inner_value_comparison? switch.
  • lib/errgonomic/core_ext/enumerable.rb: sequence_options and sequence_results were the only readers of value outside the classes; they collect through tap_some and tap_ok.
  • README.md: the pattern-matching examples take the Rust shape, the value-not-a-slot paragraph says why value is absent, and a Strict equality section states the raise surface.
  • CHANGELOG.md: the 0.10.0 section and its upgrading note.

Every reproduction line in the four issues is a doctest on the method it exercises.

Evidence

Run locally on 2026-09-10, on aarch64-darwin inside the flake devshell:

  • bundle exec rubocop: 23 files inspected, 6 offenses, all six pre-existing on main (Metrics on method_missing in option.rb and result.rb, Metrics on optional_dig, Style/Documentation on Errgonomic::Rails in active_record_optional.rb). None is in code this branch touches.
  • bundle exec yard doctest: 187 runs, 486 assertions, 0 failures, 0 errors.
  • bundle exec rake test: 132 runs, 464 assertions, 0 failures, 0 errors. This is the Rails integration suite, now under strict equality unconditionally.
  • bundle exec rake: both of the above, same counts.
  • nix build .#errgonomic: built errgonomic-0.10.0, with rake in its check phase.
  • nix flake check --all-systems: all checks passed.
  • A probe script loaded active_record, action_view, active_storage, action_text and rails and found Some, None, Ok and Err undefined at top level, so the constants collide with nothing Rails defines.

CI runs yard doctest and rake test on Ruby 3.4 on ubuntu-latest, the coverage this machine cannot give.

What is left open

  • The Rails integration reached for .value nowhere, and the Rails suite and the Rails doctests passed under strict equality with no seam changed, so the two lists the charter asked for are empty. That is evidence from this gem's own suite only; the sprout-validation run against luis/prod-1613-mass-conversion-probe is where the application-side blast radius is measured. Recommendation: accept, and read the Sprout count before the release is cut.
  • The top-level constants are the line the decision note said the driver may strike. Striking them is one deletion in each of option.rb and result.rb plus the README examples reverting to the long class names; the deconstruct shape does not depend on them. Recommendation: keep them, since in Some(v) is the whole point of the shape.

Background

Project charter: cowork/projects/2026-09-10-errgonomic-0.9.x-triage/README.md, with the three decisions under decisions/ dated 2026-09-10.

nz added 11 commits September 10, 2026 10:33
or already refused a bare operand with Errgonomic::ArgumentError.
and, xor, zip and zip_with did not: and handed the bare operand back
out of the Option chain, xor and zip fell into a NoMethodError on
some? or none?, and whether anything fired at all depended on the
receiver's variant, so a None-heavy path never learned its operand was
wrong. The check now runs before the discriminant is consulted, on all
five combinators, with or's message.
Some, Ok and Err exposed value as a public accessor, so a wrapper could
be mutated through an alias, a Hash key could move buckets under its
own hash, and the reader reached the inner value with no None branch,
the ambiguous failure the type exists to prevent. The reader is now
protected, for the sibling reads equality, ordering and zip need, and
every instance freezes on construction. A call to value or value= from
outside reaches method_missing and gets the combinator teaching.

sequence_options and sequence_results were the only readers outside
the classes; they collect through tap_some and tap_ok instead.
deconstruct answered [self, value] for a Some, an Ok and an Err, and
[None] for a None, so the Rust-shaped pattern `in Some(v)` bound the
wrapper to v under an else and missed without one, nesting such as
`in Ok(Some(v))` could not be written, and None was asymmetric with
Some. It now answers [value] for Some, Ok and Err and [] for None, the
shape Data.define(:value) and Rust's tuple variants share, so a
two-branch case/in with no else is exhaustive and a wrong type reaches
NoMatchingPatternError. There is no deconstruct_keys: a one-payload
sum type has no named field, and a Some around a Hash nests through
the Hash's own protocol as `in Some({id:})`.

Some, None, Ok and Err are also defined as top-level constants beside
the constructors of the same name, so a pattern reads as Rust does.
Rails defines none of the four.
Some(1) == 1 answering false is the silent wrong branch that mirrors
a wrapper written into a string, and 0.9.0 had the polarity backwards
with the raise opt-in. ==, !=, eql? and === between an Option or a
Result and anything that is not one now always raise
Errgonomic::TypeMismatchError with the 0.9.0 message, and === is
defined so the message names the operator that was written.
Errgonomic.strict_equality=, strict_equality? and
with_strict_equality are gone, which also removes the process-global
flag that leaked across threads.

eql? is not exempted. The raise surface is uneven because Ruby's
hashing compares hash values first and asks eql? only of a candidate
whose hash matches, so Hash#[], Set and uniq stay quiet where
Array#include?, Array#-, Array#== and case/when compare pairwise and
raise. Strict equality never answers wrong; it only sometimes fails to
catch. nil == Some(1) is answered by NilClass and cannot be
intercepted. The doctests on == state each of these.
Cross-type equality raises unconditionally now, so the second run of
the Rails suite with the flag on is the same run as rake test. The
task, its support file, the CI step and the CONTRIBUTING rung go.
lenient_inner_value_comparison? and its writer were read by nothing,
and the reader's ||= true could never be turned off anyway.
Err() carries an internal sentinel in place of a value, and a sentinel
must never bind to a pattern variable. It now deconstructs to [], so
`in Err` and `in Err()` match it and `in Err(e)` matches only an Err
that carries a value.
@nz nz closed this Sep 10, 2026
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.

1 participant