Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
## [Unreleased]

## [0.9.2] - 2026-09-10

This release makes `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did.

### Upgrading from 0.9.1

`Option#and`, `#xor`, `#zip` and `#zip_with` raise `Errgonomic::ArgumentError` on a bare operand, on a `None` receiver as well as a `Some`. Code that passed a bare value to `and` and read it back has to wrap it.

### Changes

- [Behavior change] `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did, raising `Errgonomic::ArgumentError` (`other must be an Option, was Integer`) before the receiver's variant is consulted. 0.9.x let `Some(2).and(3)` hand back the bare `3`, let `None().and(3)` and `None().zip(2)` accept the operand silently, and let `Some(1).zip(2)` and `Some(:l).xor(:r)` fall into a bare `NoMethodError` on `some?` or `none?`

## [0.9.1] - 2026-09-10

This release reverts the 0.9.0 change that made `to_s` render an Option or a Result. The raise is back, with a message that says what to call instead.

### Upgrading from 0.9.0

`to_s` on an Option or a Result raises `Errgonomic::SerializeError` again, so a string built from a wrapped reader fails where it is built rather than writing `Some("...")` or `None` into it. Code written against 0.9.0's rendering, whether a string interpolation, an `Array#join`, a `format`, a `String()` or a bare ERB `<%= %>`, has to take the value first: `unwrap_or` or `expect!` for the value, or `inspect` for a log line. A `rescue` that interpolates a wrapper into its message writes `inspect` there. A `rescue Errgonomic::SerializeError` written against 0.8.x still matches. A `logger.info(opt)` that rendered through 0.9.0 still renders through a plain `Logger`, but raises under Rails' `TaggedLogging` once a tag such as `request_id` is set, so it can pass in tests and raise in production: write `logger.info(opt.inspect)`. The README's Option section describes this and a `case/in` that matches nothing on a wrapper, whose `NoMatchingPatternError` no longer prints its subject.

### Changes

- [Behavior change] `to_s` on an Option or a Result raises `Errgonomic::SerializeError` where 0.9.0 rendered it as `inspect` does. The message names the value with its `inspect`, bounded to 60 characters, says `to_s` is refused, and names `inspect` for a log line and `unwrap_or` / `expect!` for the value: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. 0.9.0's rendering wrote wrapper text into data at every site that built a string from a wrapped reader, with no exception to find the site by: a UNIQUE identity column, a hostname, a hashed auth token and a customer-facing page. A raise that names the remedy serves the log-line case 0.9.0 traded for, and `inspect` is unchanged
- An Option or a Result in a Hash key raises on its way to JSON again. The json gem and ActiveSupport's `as_json` both stringify a key with `to_s`, so 0.9.0's rendering let `{ Some(1) => 2 }.as_json` write `{"Some(1)" => 2}` where a value position had always raised
- `Result#unwrap_err!` on an `Ok` raises an `Errgonomic::UnwrapError` whose message is the Ok's value as `inspect` renders it, bounded to 60 characters, and whose `value` is the value itself. The message used to be the value's `to_s`, so once `to_s` refuses, `Ok(Some(1)).unwrap_err!` printed only the class name and its `message` raised. A String value is quoted in the message, as `inspect` quotes it

## [0.9.0] - 2026-09-08

This release turns the ActiveRecord integration from a set of wrapped readers into a full set of boundaries, covering readers, writers, query binds, validation and serialization, with the behavior changes named in the bullets below.
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
errgonomic (0.9.0)
errgonomic (0.9.2)
concurrent-ruby (~> 1.0)

GEM
Expand Down Expand Up @@ -385,7 +385,7 @@ CHECKSUMS
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5
errgonomic (0.9.0)
errgonomic (0.9.2)
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,13 @@ in Errgonomic::Option::None
end
```

An unhandled Option refuses to leak into your output: `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#<Errgonomic::Option::Some...>` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration).
An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `Some("...")` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration).

`to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant.
The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`.

Two places reach `to_s` on your behalf, and read badly when it refuses. The first is a `case/in` with more than one `in` branch where none matches. Ruby raises `NoMatchingPatternError` with the unmatched Option or Result itself as the message, and printing that message calls `to_s`. Uncaught, it prints as a bare `NoMatchingPatternError` with no subject, and inside a Minitest test the reporter crashes on it with `Some(1) refuses to_s; …` before it prints the test's name or the run summary. The subject is a variant the `case` has no branch for, such as a Result handed to Option patterns, so the fix is the missing `in` branch, or an `else`; an `inspect` has nowhere to go. A single-branch `case/in` and a rightward `=>` build their message with `inspect` and print normally.

The second is a logger. `logger.info(opt)` renders `Some(1)` through a plain `Logger`, whose formatter calls `inspect` on a message that is not a String, but raises through Rails' `ActiveSupport::TaggedLogging` once a tag is set, because the tagged formatter interpolates the message. Rails' generated `production.rb` tags every request with `log_tags = [:request_id]`, so the same line passes in tests and raises in production. Write `logger.info(opt.inspect)`.

`expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged.

Expand Down Expand Up @@ -177,7 +181,7 @@ in Errgonomic::Result::Err, Exception => e
end
```

Like Options, unwrapped Results refuse `to_json` and `as_json`, and render `to_s` as `inspect` does. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.
Like Options, unwrapped Results refuse `to_s`, `to_json` and `as_json`, and render through `inspect`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result.

### Optional collections

Expand Down
66 changes: 49 additions & 17 deletions lib/errgonomic/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -594,10 +594,10 @@ def ok_or_else(&block)
# @example
# None().or(Some(1)) # => Some(1)
# Some(2).or(Some(3)) # => Some(2)
# None().or(2) # => raise Errgonomic::ArgumentError.new, "other must be an Option, was Integer"
# None().or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
# Some(1).or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
def or(other)
raise ArgumentError, "other must be an Option, was #{other.class.name}" unless other.is_a?(Any)

option_operand!(other)
return self if some?

other
Expand All @@ -620,12 +620,17 @@ def or_else(&block)
val
end

# If self is Some, return the provided other Option.
# If self is Some, return the provided other Option. The operand is
# checked on both variants, so a None-heavy path still learns that it
# was handed a bare value.
#
# @example
# None().and(Some(1)) # => None()
# Some(2).and(Some(3)) # => Some(3)
# Some(2).and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
# None().and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
def and(other)
option_operand!(other)
return self if none?

other
Expand Down Expand Up @@ -657,7 +662,10 @@ def and_then(&block)
# None().zip(Some(1)) # => None()
# Some(1).zip(None()) # => None()
# Some(2).zip(Some(3)) # => Some([2, 3])
# Some(1).zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
# None().zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
def zip(other)
option_operand!(other)
return None() unless some? && other.some?

Some([value, other.value])
Expand All @@ -671,25 +679,32 @@ def zip(other)
# None().zip_with(Some(1)) { |a, b| a + b } # => None()
# Some(1).zip_with(None()) { |a, b| a + b } # => None()
# Some(2).zip_with(Some(3)) { |a, b| a + b } # => Some(5)
# Some(1).zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
# None().zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer"
def zip_with(other, &block)
option_operand!(other)
return None() unless some? && other.some?

other = block.call(value, other.value)
Some(other)
end

# Render as inspect does. Rust gives Option a Debug and no Display, so
# refusing was faithful, but a to_s that raises replaces the real
# exception while a rescue builds its log line, and the rendered form
# says plainly that a wrapper arrived where a value was meant.
# Refuse to render as a String. Rust gives Option a Debug and no
# Display: a wrapper that reaches a string went unhandled, and a string
# is where it turns into data, a hostname, a hash key or a page. The
# refusal names the value and says how to log it or take it.
#
# @example
# Some(1).to_s # => "Some(1)"
# Some("x").to_s # => "Some(\"x\")"
# None().to_s # => "None"
# "value: #{Some(1)}" # => "value: Some(1)"
# Some(1).to_s # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# None().to_s # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# "value: #{Some(1)}" # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# [Some("org"), Some("metrics")].join("/") # => raise Errgonomic::SerializeError, "Some(\"org\") refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# format("%s", None()) # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# String(Some(1)) # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# Some("a" * 100).to_s # => raise Errgonomic::SerializeError, "Some(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# Some(1).inspect # => "Some(1)"
def to_s
inspect
raise Errgonomic::SerializeError, to_s_refusal
end

# Refuse to serialize an unwrapped Option as JSON. Not only should we
Expand Down Expand Up @@ -763,8 +778,10 @@ def flatten
# Some(:left).xor(Some(:right)) # => None()
# Some(:left).xor(None()) #=> Some(:left)
# None().xor(Some(:right)) #=> Some(:right)
#
# Some(:left).xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol"
# None().xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol"
def xor(other)
option_operand!(other)
return self if some? && other.none?
return other if other.some? && none?

Expand All @@ -773,12 +790,27 @@ def xor(other)

private

# Checked before the discriminant is consulted, so a None-heavy path
# learns about a bare operand as soon as a Some-heavy one would.
def option_operand!(other)
return if other.is_a?(Errgonomic::Option::Any)

raise Errgonomic::ArgumentError, "other must be an Option, was #{other.class.name}"
end

def to_s_refusal
"#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
end

def serialize_refusal
"cannot serialize an unwrapped #{bounded_inspect}"
end

# Name the value the caller failed to handle, bounded: an inspect of a
# record or a long payload would bury the message carrying it.
def serialize_refusal
def bounded_inspect
rendered = inspect
rendered = "#{rendered[0, 57]}..." if rendered.length > 60
"cannot serialize an unwrapped #{rendered}"
rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered
end

def presence_nudge(from, to)
Expand Down
41 changes: 30 additions & 11 deletions lib/errgonomic/result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,16 @@ def expect!(msg = nil, &block)
end

# Return the inner value of an Err, else raise an exception when Ok.
# The message is the Ok's value as inspect renders it, bounded, so an
# Ok holding an Option or a Result still has a message to print.
#
# @example
# Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, 1
# Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, "1"
# Ok(Some(1)).unwrap_err! # => raise Errgonomic::UnwrapError, "Some(1)"
# Ok("a" * 100).unwrap_err! # => raise Errgonomic::UnwrapError, "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..."
# Err(:e).unwrap_err! # => :e
def unwrap_err!
raise Errgonomic::UnwrapError, value unless err?
raise Errgonomic::UnwrapError.new(bounded_inspect(value), value) unless err?

@value
end
Expand Down Expand Up @@ -405,18 +409,22 @@ def map_err(&block)
Err(block.call(value))
end

# Render as inspect does. Rust gives Result a Debug and no Display, so
# refusing was faithful, but a to_s that raises replaces the real
# exception while a rescue builds its log line, and the rendered form
# says plainly that a wrapper arrived where a value was meant.
# Refuse to render as a String. Rust gives Result a Debug and no
# Display: a wrapper that reaches a string went unhandled, and a string
# is where it turns into data, a hostname, a hash key or a page. The
# refusal names the value and says how to log it or take it.
#
# @example
# Ok(1).to_s # => "Ok(1)"
# Err(:nope).to_s # => "Err(:nope)"
# Err().to_s # => "Err()"
# "outcome: #{Ok(1)}" # => "outcome: Ok(1)"
# Ok(1).to_s # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# Err(:nope).to_s # => raise Errgonomic::SerializeError, "Err(:nope) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# "outcome: #{Ok(1)}" # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# [Ok(1), Err(:x)].join(",") # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# format("%s", Err(:x)) # => raise Errgonomic::SerializeError, "Err(:x) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# String(Ok(1)) # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# Err("a" * 100).to_s # => raise Errgonomic::SerializeError, "Err(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
# Err(:nope).inspect # => "Err(:nope)"
def to_s
inspect
raise Errgonomic::SerializeError, to_s_refusal
end

# Refuse to serialize an unwrapped Result as JSON. Not only should we
Expand Down Expand Up @@ -471,6 +479,17 @@ def deconstruct

private

def to_s_refusal
"#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value"
end

# Name the value the caller failed to handle, bounded: an inspect of a
# record or a long payload would bury the message carrying it.
def bounded_inspect(object = self)
rendered = object.inspect
rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered
end

def strict_equality!(other, operator)
return unless Errgonomic.strict_equality?
return if other.is_a?(Errgonomic::Result::Any)
Expand Down
2 changes: 1 addition & 1 deletion lib/errgonomic/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Errgonomic
VERSION = '0.9.0'
VERSION = '0.9.2'
end
Loading
Loading