Skip to content

Take the audit's ergonomics decisions: presence, to_s, strict equality, try, map_or, each - #73

Merged
nz merged 11 commits into
mainfrom
nz/audit-ergonomics
Sep 9, 2026
Merged

nz merged 11 commits into
mainfrom
nz/audit-ergonomics

Conversation

@nz

@nz nz commented Sep 9, 2026

Copy link
Copy Markdown
Member

Seven decisions from the Sprout conversion audit (2026-09-08), one commit each, all specified as doctests.

  • presence is supported again as the Rails spelling of unwrap_or(nil), with the discriminant semantics the README states (Some("").presence is ""). The remaining soft-deprecated presence helpers nudge once per process instead of on every call.
  • Behavior change. to_s on an Option or Result renders as inspect does (Some(1), None, Ok(1), Err(:x)) instead of raising. Rust gives Option Debug but not Display, so raising was faithful, but a to_s that raises while a rescue builds a log line replaces the real exception. to_json and as_json still raise. expect! and present_or_raise! take a block for a lazily built message so interpolating an Option into an eager message cannot raise on the success path.
  • Opt-in strict equality: Errgonomic.strict_equality = true or with_strict_equality { } raises TypeMismatchError on ==, !=, and eql? between a container and anything that is not the same kind of container, nil included, naming the spelling to use. It is the systematic detector for the silent-equality class the audit found (Some(x) == x quietly false in case, include?, and guards). The gem's own Rails suite runs under it via rake test:strict, in the default task and CI.
  • Option#try and try! forward to the inner value when ActiveSupport is loaded, so some.try(:m) no longer answers nil.
  • map keeps Rust semantics (a block returning an Option nests); the docs name and_then as the spelling.
  • Behavior change. map_or and map_or_else return the bare value, as Rust's do; they returned Some(x).
  • Option#each yields the inner value once for Some and nothing for None, without including Enumerable. The README states that take, replace, insert, and get_or_insert are deliberately omitted and why.

Also found on the way: the default Rake task never ran anything listed after yard:doctest, which exits the process; the order is now tests, strict tests, doctests.

Verified locally: rubocop (no new offenses; one pre-existing offense removed), rake test, rake test:strict, yard doctest across seeds, nix build .#errgonomic, nix flake check --all-systems.

nz added 11 commits September 8, 2026 20:50
`presence` on an Option answers the inner value of a Some and nil for a
None, which is exactly what unwrap_or(nil) says and what a Rails
developer already reaches for, so it is supported rather than nudged
toward a combinator. It follows the discriminant like every other
presence question here: Some("") answers "" where Object#presence
answers nil.

The nudge from the remaining soft-deprecated spellings now fires once
per process per method. A soft deprecation is a message to a developer,
and one per call turns a hot path into a stderr flood.
to_s answers what inspect answers: "Some(1)", "None", "Ok(1)",
"Err(:x)". Rust gives these types a Debug and no Display, so refusing
was a faithful reading, but a to_s that raises replaces the real
exception while a rescue builds its log line, which is the worst place
to be strict about a wrapper. The rendered form leaks nothing silently:
a Some(1) in a log says plainly that a wrapper arrived where a value
was meant.

to_json and as_json still raise, because a payload has no such tell.
A message that names what is missing usually interpolates the thing
that is absent, and an eager argument pays for that on every call that
succeeds. A block is called only on the branch that raises, so
`tier.expect! { "no tier for #{account.id}" }` costs nothing on the
happy path. present_or_raise! takes one on the same terms, and the
positional message keeps working.
A wrapper compared to a plain value answers false, quietly, because
that is what every Ruby object does across types and because raising
would break Array#include?, assertion diffs and dirty tracking.
Errgonomic.strict_equality = true makes ==, != and eql? raise a
TypeMismatchError instead, naming both classes and the spelling to
reach for, and nil counts as another type so None() == nil points at
none?. Two Options still compare, and hash is untouched, so an Option
stays a usable Hash key.

It is meant for a test suite: rake test:strict runs this gem's Rails
integration suite with it on, and nothing under ActiveRecord compares a
wrapper cross-type today.
ActiveSupport's Object#try asks respond_to? first, and an Option answers
false for anything it does not define, so try on a wrapper was a quiet
nil for every method and a block was handed the wrapper rather than the
value. Both forms now reach the value: a Some tries what it holds, a
None answers nil, and a method the value does not have is nil as it is
for any other receiver. try! is the strict variant, raising for that.

They live with the rest of the ActiveSupport-dependent patches, so a
checkout without Rails is unchanged.
map wraps whatever the block hands back, which is Rust's semantics and
stays. The nesting is invisible until something reaches for the inner
value, so the README and the method now say it outright and name
and_then as the spelling for a block that returns an Option. The
docstring also dropped a pedantic runtime check it describes and does
not do.
map_or and map_or_else are the exit from an Option: they take a default
for the absent case precisely so the caller can stop handling one. Both
wrapped their answer, so a caller got Some(4) where Rust gives 4 and
had to unwrap the thing it had just supplied a default for. They now
hand back what the default or the block gives. map is the combinator
that stays inside the Option.

This is a breaking change for anything reading their return value.
An Option is a collection of zero or one, and each says so: a Some
yields its value once, a None yields nothing, and without a block you
get an Enumerator, which is where the rest of Enumerable lives.

Enumerable itself stays out. filter and first are already Option's own
and answer Options; Enumerable's answer plain values, and one name
cannot mean both.
take, replace, insert and get_or_insert each write through an
&mut Option, and an Option here is a value rather than a slot. That
reasoning was a comment at the foot of the class, where nobody deciding
whether the gem covers Rust's surface would look for it; the README's
Option section is where that question is asked.

Removing the comment takes the access modifier that carried it, and the
lint offense that came with it.
Some(1) == Ok(1) raised with a remedy that read "compare to
Some(Ok(1))", which is not what anyone writing that line meant. An
Option and a Result are different containers and comparing them is a
type mistake, so it keeps raising, but the message now names both and
says to unwrap whichever one was meant.

!= also names itself. Ruby derives it from ==, so the message used to
report an operator the caller had not written.

The README says which comparisons the check reaches: the wrapper as
receiver, and a left operand like Integer that hands the comparison to
its right-hand side, while NilClass and String answer for themselves
and stay quietly false.
test:strict spawned a bare ruby, so --seed reached rake test and not the
strict suite; TESTOPTS is split and forwarded now.

An Option's Enumerator knows its size, so each.size answers 1 or 0
rather than nil. The nudge names present_or_raise! with the bang the
caller wrote. The doctests that swap $stderr restore it from an ensure,
so an example that fails partway cannot take the rest of the run's
warnings with it.
@nz
nz merged commit 2acde23 into main Sep 9, 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