Skip to content

Always raise on cross-type equality, drop the strict flag, and release 0.10.1 - #86

Closed
nz wants to merge 37 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/strict-equality
Closed

nz wants to merge 37 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/strict-equality

Conversation

@nz

@nz nz commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Errgonomic 0.10.1 makes ==, !=, eql? and === between an Option or a Result and anything that is not one raise Errgonomic::TypeMismatchError unconditionally, and removes the switch that used to turn that on. Some(1) == 1 answering false is the silent wrong branch that mirrors a wrapper written into a string, and 0.9.0 had the safe behavior opt-in. This pull request stacks on the 0.10.0 branch (#85) and targets main, so until #85 merges its diff also shows the earlier releases' commits; it merges after that one.

Closes #79.

What changes

  • A cross-type ==, !=, eql? or === always raises, with the message 0.9.0 gave under the flag. === is defined so case 5 when Some(5) and a pinned pattern raise under the operator that was written.
  • Errgonomic.strict_equality=, strict_equality? and with_strict_equality are removed, and with them the process-global flag that leaked across threads. rake test:strict, its support file and its CI step are removed: the Rails suite runs under strict equality as rake test.
  • Errgonomic.lenient_inner_value_comparison? and its writer are removed; nothing read them.
  • Version 0.10.1, with a changelog section whose upgrading note names the removed methods and the spellings to compare with.

How it works

  • lib/errgonomic/option.rb and lib/errgonomic/result.rb: strict_equality! no longer consults a flag, and === is defined on both containers. The doctests on == state the raise surface, corrected after review as described below; nil == Some(1) and "a" == Some("a") are answered by NilClass and String and cannot be intercepted.
  • lib/errgonomic.rb: the flag, its block form and the unread lenient switch are gone.
  • README.md: a Strict equality section states the surface, with the rule that strict equality never answers wrong and only sometimes fails to catch.
  • Rakefile, .github/workflows/main.yml and CONTRIBUTING.md: the test:strict task, step and rung are removed.

eql? is not exempted, as #79 suggested it might be. Exempting it would make Array#- answer wrong where raising only fails to catch, and the surface is stated instead.

Evidence

Run locally on 2026-09-10, 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).
  • bundle exec yard doctest: 187 runs, 486 assertions, 0 failures, 0 errors. The Rails doctests run under strict equality.
  • bundle exec rake test: 132 runs, 464 assertions, 0 failures, 0 errors. The Rails suite passed under strict equality with no seam changed.
  • bundle exec rake: the same two counts.
  • nix build .#errgonomic: built errgonomic-0.10.1.
  • nix flake check --all-systems: all checks passed.
  • A probe script measured the raise surface before the flag was removed; the README and the == doctests carry its findings.

CI runs yard doctest and rake test on Ruby 3.4 on ubuntu-latest.

What is left open

The Rails integration compared a wrapper cross-type nowhere its suite reaches, so no seam changed. That is evidence from this gem's suite only; the Sprout validation run measures the application-side blast radius. Recommendation: accept, and read the Sprout count of raises before the release is cut.

Background

Project charter: cowork/projects/2026-09-10-errgonomic-0.9.x-triage/README.md, decision decisions/2026-09-10-strict-equality-is-the-only-equality.md.

After review

  • The README's compromise list and the Rails integration's comment said None() == nil is false; both now say it raises.
  • The raise surface was stated for one operand order. Measured method by method, a pairwise == raises when the wrapper is the one asked and otherwise only when the bare value is a Numeric; Array#-, #& and #| compare with eql? on short arrays, where only the wrapper's own eql? raises ([Some(1)] - [1] raises, [1] - [Some(1)] is quiet, [1] | [Some(1)] raises), and hash past 16 members; Hash#[], Set#include?, uniq and group_by stay quiet either way. The README lists it, and a test pins every case it names.
  • The upgrading note compared against 0.9.x; it now says 0.10.0 answered false unless strict equality was on.

The earlier branches' review fixes are merged forward. Gates, 2026-09-10: rubocop 6 offenses, the same six; yard doctest 191 runs, 499 assertions; rake test 143 runs, 641 assertions; nix build and nix flake check --all-systems pass. CI passed on 76a5dc8.

nz added 18 commits September 10, 2026 10:25
0.9.0 made Option#to_s render as inspect does, to keep a raise out of
the rescue block that interpolates a wrapper into its log line. In a
mass conversion of every model in an application, every site that
built a string from a reader then wrote wrapper text into data with no
exception: a UNIQUE identity column, a hostname, a hashed auth token
and a customer-facing page. Each of those raised under 0.8.3.

The raise comes back as the same Errgonomic::SerializeError, so an
existing rescue still matches, and its message now carries the remedy
the log-line case needs: the value via the bounded inspect, that to_s
is refused, and inspect for a log line or unwrap_or / expect! for the
value. The bounded inspect moves into its own helper so the to_json
refusal and this one name the value the same way.
The same reversal as for Option, with the same message shape: the
value via a bounded inspect, that to_s is refused, and the spellings
to reach for. Result had no bounded inspect helper, so one is added
privately for the to_s refusal; the to_json and as_json refusals keep
their fixed message.
Each reproduction from the 0.9.0 field reports is a test: join,
interpolation, format and String() for both containers; a bare ERB
<%= %> through ActionView, whose template error carries the refusal as
its cause; and an Option or Result in a Hash key, which both the json
gem and ActiveSupport's as_json stringify through to_s, so the raising
to_s closes the key position that 0.9.0 opened.

A None in a bare ERB tag renders as nothing rather than raising: the
Rails integration's None answers nil?, and ActionView's output buffer
skips a nil before it asks for to_s. The test records that as it is.
The changelog names the to_s reversal as a behavior change with an
upgrading note for anyone who wrote against 0.9.0's rendering. The
0.9.0 section stays as history.
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.
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.
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.
nz added 9 commits September 10, 2026 12:27
unwrap_err! passed the Ok's value as the exception message, so the
message was the value's to_s. With to_s refused on an Option or a
Result, Ok(Some(1)).unwrap_err! printed only the class name and its
message raised. The message is now the value's inspect, bounded as the
to_s refusal bounds it, and the error carries the value as unwrap!'s
does.
The join test covered the same shape with different values, and the
group_by test grouped bare Options rather than a converted model's
reader. Both lines now appear as #75 writes them: a join of a Some and
a None, and a group_by over a converted model's optional association
on its way to JSON.
With ActiveSupport loaded, Hash#to_json reaches a key through as_json,
which refused an Option under 0.9.0 as well, so the two to_json
assertions in the hash-key test passed before the to_s change and did
not test it. They move to a child process that loads the json gem and
errgonomic alone, where 0.9.0 rendered {"Some(1)":2} and a raising to_s
refuses.
A case/in with several branches and none matching raises
NoMatchingPatternError with the subject itself as its message, so on
an Option or a Result the message refuses. The top-level printer then
shows only the class name, and Minitest's reporter crashes before it
names the test. The README says so and that the fix is the missing
branch, and a test pins each of the three behaviors so the text cannot
drift from them. The json-gem child test shares the new run_ruby
helper.
Logger's own formatter inspects a message that is not a String, so
logger.info(opt) renders. ActiveSupport::TaggedLogging interpolates the
message once a tag is set, and Rails tags every production request with
its request_id, so the same call passes in tests and raises in
production. The README and the 0.9.1 upgrading note say to write
logger.info(opt.inspect), and a test pins both loggers' behavior.
…26-09-10-errgonomic-0.9.x-triage/value-is-not-a-slot
The changelog and README said every instance is frozen, but a copy
that skips construction is not: dup, Marshal.load, a YAML load and
ActiveSupport's deep_dup all return an unfrozen instance, while clone
keeps it frozen. With no writer, such a copy changes only through
instance_variable_set. The wording now states that guarantee.
… into 2026-09-10-errgonomic-0.9.x-triage/deconstruct
nz added 10 commits September 10, 2026 12:50
The top-level Some, None, Ok and Err were the four classes, so a
paren-less None, Rust's spelling of the value, evaluated to the class
and its name reached a hostname, a JSON payload or a column with no
exception. An application's own class None reopened the gem's, and
one defined before the gem loaded was replaced with only a warning.

Each name is now an Errgonomic::VariantName: it matches as its class
in a pattern and a case/when, inspects as the class, and raises
SerializeError from to_s, to_json and as_json. Not being a class, an
application's class None or module Ok written after the gem loads
raises TypeError, and defining the names refuses a constant the
application already holds with a NameError. The classes themselves
are untouched, so their inspect, name and every message that names
them render as before.
A string or json column already refused a bare None through to_s, but
a boolean column cast it to true and an integer column to nil, and a
where or a bulk write bound it the same way. The Rails integration
unwraps an Option at every write, query and bulk boundary before a
column type sees it, so it refuses a bare variant name there too.
The README says a bare Some, None, Ok or Err is a name for patterns,
that it refuses to stand in for a value, that is_a? takes the full
class name, and how an application constant of the same name fails,
including the two forms that only warn or defer: an assignment after
the gem loads, and a file Zeitwerk skips. The 0.10.0 changelog says
the same.
The doctests for a value that falls through the two-branch patterns
asserted only the error's class, so they could not show that its
message refuses to print when the subject is an Option or a Result.
Each now shows the message of a plain subject, and a second example
shows a wrapper subject whose message raises SerializeError. The
case/in test from 0.9.1 takes the Rust-shaped patterns.
…26-09-10-errgonomic-0.9.x-triage/strict-equality
Strict equality made None() == nil raise TypeMismatchError, pointing at
none?, but the README's compromise list and the Rails integration's own
comment still said it was false. Both now say it raises, and the README
adds that nil == None() is false because NilClass answers it.
The README called Array#- raising and Array#| quiet, and listed the
pairwise == operations as raising, but each of those holds for one
operand order only. A pairwise == raises when the wrapper is asked, and
otherwise only when the bare value is a Numeric that hands the
comparison back. Array#-, #& and #| compare with eql? while the arrays
are short, and only the wrapper's own eql? raises; past Ruby's
16-member cutoff they hash and stay quiet. The README and changelog now
say so, a doctest shows Array#| both ways, and a test pins every case
the README names.
The note under Upgrading from 0.10.0 said a cross-type comparison
raises where 0.9.x answered false. 0.10.0 answered false too unless
strict equality was switched on, and that is the release an upgrade
from 0.10.0 leaves.
kind_of?, instance_of?, .new, <=, .name and Minitest's assert_kind_of
all raise on Some/None/Ok/Err the same way is_a? does, since none of
them go through anything but Object's own type machinery. Spell each
against the fully qualified class or the some?/none?/ok?/err? readers
instead, and pin one case as a doctest.
…26-09-10-errgonomic-0.9.x-triage/strict-equality
@nz
nz force-pushed the 2026-09-10-errgonomic-0.9.x-triage/strict-equality branch from bbdbab6 to 2d73271 Compare September 10, 2026 18:31
@nz
nz marked this pull request as ready for review September 10, 2026 18:32
@nz
nz enabled auto-merge September 10, 2026 18:32
@nz
nz disabled auto-merge September 10, 2026 20:15
@nz

nz commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Superseded by #87, which landed this release's commits on main as a linear history (merged 2026-09-10).

@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.

strict_equality is process-global (thread-unsafe) and raises from inside Hash/Array internals

1 participant