Repository navigation
Conversation
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
changed the base branch from
2026-09-10-errgonomic-0.9.x-triage/deconstruct
to
main
September 10, 2026 16:06
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.
…6-09-10-errgonomic-0.9.x-triage/and-xor-zip
…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
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
force-pushed
the
2026-09-10-errgonomic-0.9.x-triage/strict-equality
branch
from
September 10, 2026 18:31
bbdbab6 to
2d73271
Compare
nz
marked this pull request as ready for review
September 10, 2026 18:32
nz
enabled auto-merge
September 10, 2026 18:32
nz
disabled auto-merge
September 10, 2026 20:15
Member
Author
|
Superseded by #87, which landed this release's commits on main as a linear history (merged 2026-09-10). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Errgonomic 0.10.1 makes
==,!=,eql?and===between an Option or a Result and anything that is not one raiseErrgonomic::TypeMismatchErrorunconditionally, and removes the switch that used to turn that on.Some(1) == 1answeringfalseis 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 targetsmain, so until #85 merges its diff also shows the earlier releases' commits; it merges after that one.Closes #79.
What changes
==,!=,eql?or===always raises, with the message 0.9.0 gave under the flag.===is defined socase 5 when Some(5)and a pinned pattern raise under the operator that was written.Errgonomic.strict_equality=,strict_equality?andwith_strict_equalityare 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 asrake test.Errgonomic.lenient_inner_value_comparison?and its writer are removed; nothing read them.How it works
lib/errgonomic/option.rbandlib/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 byNilClassandStringand 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.ymlandCONTRIBUTING.md: thetest:stricttask, step and rung are removed.eql?is not exempted, as #79 suggested it might be. Exempting it would makeArray#-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 onmain(Metrics onmethod_missinginoption.rbandresult.rb, Metrics onoptional_dig, Style/Documentation onErrgonomic::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: builterrgonomic-0.10.1.nix flake check --all-systems: all checks passed.==doctests carry its findings.CI runs
yard doctestandrake teston 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, decisiondecisions/2026-09-10-strict-equality-is-the-only-equality.md.After review
None() == nilisfalse; both now say it raises.==raises when the wrapper is the one asked and otherwise only when the bare value is a Numeric;Array#-,#&and#|compare witheql?on short arrays, where only the wrapper's owneql?raises ([Some(1)] - [1]raises,[1] - [Some(1)]is quiet,[1] | [Some(1)]raises), and hash past 16 members;Hash#[],Set#include?,uniqandgroup_bystay quiet either way. The README lists it, and a test pins every case it names.falseunless strict equality was on.The earlier branches' review fixes are merged forward. Gates, 2026-09-10: rubocop 6 offenses, the same six;
yard doctest191 runs, 499 assertions;rake test143 runs, 641 assertions;nix buildandnix flake check --all-systemspass. CI passed on76a5dc8.