Skip to content

Refuse to_s on an Option or a Result again, and release 0.9.1 - #81

Merged
nz merged 10 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/to-s-raise
Sep 10, 2026
Merged

nz merged 10 commits into
mainfrom
2026-09-10-errgonomic-0.9.x-triage/to-s-raise

Conversation

@nz

@nz nz commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Errgonomic 0.9.1 makes Option#to_s and Result#to_s raise Errgonomic::SerializeError again, as 0.8.3 did, with a message that names the value and says what to call instead. Closes #80 and closes #75. The reviewer is asked to confirm one thing: #80 asks for an Errgonomic.to_s_behavior flag, and this change declines it in favor of an unconditional raise, because the one case rendering serves, a log line inside a rescue, is already served by inspect.

What changes

A string built from an Option or a Result fails where it is built. String interpolation, Array#join, format, String(), a bare ERB <%= %> and a Hash key on its way to JSON all reach the value through to_s, and each raises rather than writing Some("...") or None into a hostname, a UNIQUE column or a page. The message is one line:

Some("hot") refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value

inspect is unchanged. A rescue Errgonomic::SerializeError written against 0.8.x still matches. The changelog carries an upgrading note for code written against 0.9.0's rendering, and the 0.9.0 section stays as history.

How it works

Evidence

Every line from #80 and #75, run on 2026-09-10 against this branch with Ruby 3.4.9, ActiveSupport 8.1.3.1 and json 2.21.2. 0.9.0 is main at 43bbd0a.

Line 0.9.0 This branch
[Some("org"), Some("metrics")].join("/") Some("org")/Some("metrics") raises
"#{None()}.us-east-1.example" None.us-east-1.example raises
"data_#{Some("hot")}" data_Some("hot") raises
format("%s", Some(1)), String(Some(1)) Some(1) raises
ERB <%= Some("visible") %> Some(&quot;visible&quot;) raises, as the cause of ActionView::Template::Error
{Some(1) => 2}.as_json {"Some(1)" => 2} raises
JSON.generate({Some(1) => 2}) {"Some(1)":2} raises
group_by { Some/None }.to_json, json gem only {"None":[1,3],"Some(:even)":[2]} raises
Some(1).inspect Some(1) Some(1)

The #75 key position closes through to_s alone: the json gem's generator and ActiveSupport's Hash#as_json both stringify a key with key.to_s. No to_str is added. One line in #75 did not reproduce as filed: with ActiveSupport loaded, {Some(1) => 2}.to_json already raised on 0.9.0, because ActiveSupport 8.1 runs a key through as_json before the generator sees it. The same call raises on this branch either way.

Gates, all run locally in the branch's devshell on 2026-09-10:

  • bundle exec rubocop: 6 offenses, all present on main at 43bbd0a and none on a changed line. See open items.
  • bundle exec yard doctest: 174 runs, 436 assertions, 0 failures, 0 errors.
  • bundle exec rake test: 132 runs, 464 assertions, 0 failures, 0 errors.
  • bundle exec rake test:strict: 132 runs, 464 assertions, 0 failures, 0 errors.
  • bundle exec rake: the three suites above, all passing.
  • nix build .#errgonomic: built errgonomic-0.9.1; the check phase ran the doctests.
  • nix flake check --all-systems: all checks passed.

CI on this branch ran yard doctest, rake test and rake test:strict on Ruby 3.4 and passed on 2026-09-10 (run 34495870058).

What is left open

  • A None in a bare ERB <%= %> renders as nothing under the Rails integration, where a Some raises. None#nil? answers true there, and ActionView's output buffer skips a nil before it asks for to_s. The test records that as it is. Recommendation: accept here. It is the nil? compromise the README names, not to_s, and a raise there means revisiting nil?.
  • rubocop reports six offenses on main that this branch does not touch: Metrics on Option#method_missing, Result#method_missing and optional_dig, and Style/Documentation on Errgonomic::Rails. CI does not run rubocop. Recommendation: fix in their own commit outside this hotfix.
  • Result#to_json and Result#as_json still say cannot serialize an unwrapped Result without naming the value, where Option's refusal names it. Recommendation: fix outside this hotfix.
  • After the merge, the 0.9.1 gem is cut from main with rake release.

After review

Review on 2026-09-10 found three places where a raising to_s reads badly and two test gaps. Following the driver's ruling that such seams are documented rather than patched, this branch now carries:

  • Result#unwrap_err! on an Ok builds its message from the value's bounded inspect, so Ok(Some(1)).unwrap_err! prints Some(1) where it printed a bare class name and its message raised. It was the only place in the gem that passed a value as an exception message. Changelog bullet under 0.9.1.
  • The README says what a multi-branch case/in that matches nothing on a wrapper looks like, a bare NoMatchingPatternError and a Minitest reporter crash with no test name, and that the fix is the missing in branch or an else. It also says logger.info(opt) raises under Rails' TaggedLogging once a tag such as request_id is set, so it can pass in tests and raise in production, and to write logger.info(opt.inspect). The upgrading note names both, and a test pins each behavior the text describes.
  • The two hash-key to_json assertions that already raised on 0.9.0 with ActiveSupport loaded are replaced by a test in a child process that loads only the json gem, where 0.9.0 rendered {"Some(1)":2}. The as_json/to_json refusal misses Option hash KEYS — wrapper ships to payloads silently (0.9.0 regression) #75 lines [Some(1), None()].join(",") and a converted model's group_by(&:author).to_json are tests as the issue writes them.

Gates after the fixes, 2026-09-10: rubocop 6 offenses, the same six; yard doctest 174 runs, 438 assertions; rake test and rake test:strict 136 runs, 480 assertions each; nix build .#errgonomic and nix flake check --all-systems pass. CI passed on 3c8ff50.

nz added 5 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.
lutzcc1
lutzcc1 previously approved these changes Sep 10, 2026
nz added 5 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.
@nz
nz force-pushed the 2026-09-10-errgonomic-0.9.x-triage/to-s-raise branch from 3c8ff50 to fd66b4f 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 merged commit d7e18c1 into main Sep 10, 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

2 participants