Skip to content

Require an error on every Err, remove Option#ok, and release 0.10.2 - #87

Merged
nz merged 33 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/err-carries-an-error
Sep 10, 2026
Merged

nz merged 33 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/err-carries-an-error

Conversation

@nz

@nz nz commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Errgonomic 0.10.2 makes every Err carry an error: Err() and Errgonomic::Result::Err.new with no argument raise ArgumentError. It also removes Option#ok, the one public method that built an Err without an error. Both are breaking changes, and the changelog's upgrading note covers each. This pull request now carries 0.9.2 through 0.10.2 as a linear history on main, and it supersedes #83, #84, #85 and #86.

What changes

  • Err() and Errgonomic::Result::Err.new with no argument raise ArgumentError; the bare Err is a VariantName from 0.10.0 on and has no new. A value-less Err held an internal placeholder, Errgonomic::Result::Err::Arbitrary, and unwrap_err! handed that placeholder back to the caller. The placeholder is deleted.
  • Err(e).deconstruct is always [e]. in Err and in Err(_) match every Err, and in Err() matches none. In 0.10.1, in Err() matched only the value-less Err.
  • Option#ok is removed. None().ok returned the value-less Err, and Rust has no Option::ok. A call to ok on an Option now raises Errgonomic::UnwrappedAccessError, which names ok_or and ok_or_else.
  • Err(nil) is unchanged. It carries nil: unwrap_err! returns nil, deconstruct answers [nil], and in Err(e) binds e = nil.
  • Version 0.10.2, with a changelog section. Its upgrading note says to replace Err() with Err(reason), in Err() with in Err or in Err(_), and opt.ok with opt.ok_or(reason).

How it works

  • lib/errgonomic/result.rb: the global Err and Result::Any#initialize take one required argument, so Ruby's own arity check raises. Err no longer defines initialize. The doctest on the Err class covers each construction path. inspect and deconstruct lose their placeholder branches, and the deconstruct doctests show the pattern behavior.
  • lib/errgonomic/option.rb: ok is deleted. A doctest on ok_or shows that None().ok and Some(1).ok raise.

No other method in the gem built a value-less Err. ok_or, ok_or_else, map_err, and the Boolean ok_or and ok_or_else already pass an error.

The ArgumentError is Ruby's arity error, "wrong number of arguments (given 0, expected 1)". A custom message would need a default argument to detect the missing error, and that default is the placeholder this change removes.

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.
  • bundle exec rake test: 132 runs, 464 assertions, 0 failures, 0 errors.
  • bundle exec rake: the same two counts.
  • nix build .#errgonomic: built errgonomic-0.10.2. Its check phase ran the same two counts.
  • nix flake check --all-systems: all checks passed.

CI runs yard doctest and rake test on Ruby 3.4 on ubuntu-latest. Run 34502848625 on 2026-09-10 passed with the same two counts.

What is left open

  • Use of Option#ok and Err() in applications is not measured here. Recommendation: accept, and read the Sprout validation count against this branch before the release is cut. If Sprout calls Option#ok widely, a deprecation release may cost less than the removal.
  • Err(nil) is decided: the driver ruled on 2026-09-10 that it is an ordinary Err holding nil. It inspects as Err(nil), unwrap_err! returns nil, it deconstructs to [nil], and in Err(e) binds nil; a doctest pins each. Revisit if Err(nil) turns out to be a recurring source of nil reaching application code.

Background

Project charter: cowork/projects/2026-09-10-errgonomic-0.9.x-triage/README.md, decisions decisions/2026-09-10-every-err-carries-an-error.md and decisions/2026-09-10-option-ok-is-deleted.md.

After review

  • A doctest shows an Err nested in an Ok matching through the Ok's payload: in Ok(Err(e)) binds the error, in Ok(Err()) matches nothing, in Ok(Err) matches any.
  • A doctest pins Err(nil) as an ordinary Err, per the ruling above.
  • The README gains a Known limitations section: a converted model's belongs_to ..., optional: true, touch: true raises Errgonomic::UnwrappedAccessError on a save or a destroy with the association absent, because a None does not delegate persisted?. It predates this series and is not fixed here; errgonomic_optional_except is the measured way around it.
  • The earlier branches' review fixes are merged forward. The Err doctest and the upgrading note reached the class through the bare Err, which 0.10.0 now makes a VariantName, so both name Errgonomic::Result::Err.new.

Gates, 2026-09-10: rubocop 6 offenses, the same six; yard doctest 193 runs, 506 assertions; rake test 143 runs, 641 assertions; nix build and nix flake check --all-systems pass. CI passed on 49a9e18.

@nz
nz force-pushed the 2026-09-10-errgonomic-0.9.x-triage/err-carries-an-error branch from acb629c to d3695ea 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
lutzcc1
lutzcc1 previously approved these changes Sep 10, 2026
@nz
nz dismissed lutzcc1’s stale review September 10, 2026 20:12

The merge-base changed after approval.

@nz
nz disabled auto-merge September 10, 2026 20:13
nz added 24 commits September 10, 2026 15:17
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.
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.
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.
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.
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.
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.
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.
None().ok was the only public method that returned a value-less Err,
and it handed the internal placeholder to anyone who unwrapped it.
Rust has no Option::ok; ok_or and ok_or_else already make the caller
name the error. A call to ok now reaches the Option's method_missing,
whose UnwrappedAccessError names ok_or and ok_or_else.
Err() and Err.new with no argument raise ArgumentError. A value-less
Err carried an internal placeholder that unwrap_err! handed back to
the caller and deconstruct had to hide, a gem-internal value leaking
into application data. With an error always present, Err(e) always
deconstructs to [e], so `in Err()` matches no Err while `in Err` and
`in Err(_)` match every one. Err(nil) is unchanged: it carries nil.
nz added 9 commits September 10, 2026 15:18
No Err can hold Err::Arbitrary any more, so the class and the branches
in inspect and deconstruct that special-cased it are unreachable.
Neither Ok nor Err defines initialize now, so the class comment's
claim that each variant handles its own initialization was wrong.
The Err doctest reached the class through the top-level Err, which
0.10.0 now makes a VariantName rather than the class, so Err.new
raised NoMethodError where the doctest expected ArgumentError. The
class path keeps showing that construction needs an error, and the
bare name shows it does not construct at all.
Every Err now deconstructs to its error, and nothing pinned how that
reads one level down. The doctest shows in Ok(Err(e)) binding the
error, in Ok(Err()) matching nothing, and in Ok(Err) matching any Err
inside an Ok.
An explicit nil counts as the error an Err carries: it inspects as
Err(nil), unwrap_err! returns nil, it deconstructs to [nil], and
in Err(e) binds e to nil. The doctest pins each of these.
The note said Err.new with no argument raises ArgumentError. The bare
Err is a VariantName from 0.10.0 on and has no new, so the note names
Errgonomic::Result::Err.new, as the changelog bullet already does.
A converted model's belongs_to ..., optional: true, touch: true raises
UnwrappedAccessError on create, update or destroy with the association
absent: ActiveRecord's touch callback asks the reader's value
persisted?, and a None does not delegate it the way a Some does. The
README gains a Known limitations section that says so and gives
errgonomic_optional_except as the way around it, measured to keep
touch: true working. The limitation predates this release and is not
fixed here.
3604d0b changed Err.new's expected exception from ArgumentError to
NoMethodError once the bare Err name stopped being a class, which
made the line demonstrate F1's constant-reflex breakage instead of
what the heading is about. Err() and the fully qualified Err.new
line beside it already pin the no-value requirement on their own.
lutzcc1
lutzcc1 previously approved these changes Sep 10, 2026
@nz
nz dismissed lutzcc1’s stale review September 10, 2026 20:20

The merge-base changed after approval.

@nz
nz force-pushed the 2026-09-10-errgonomic-0.9.x-triage/err-carries-an-error branch from d3695ea to 36f94e6 Compare September 10, 2026 20:21
@nz
nz enabled auto-merge (rebase) September 10, 2026 20:23
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