From 8fe2617f92549714e7bb888a315af2d5822b90c0 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Fri, 7 Aug 2026 18:13:57 -0500 Subject: [PATCH] Refuse to serialize an unwrapped container from as_json The serialization guard only held at the top level. ActiveSupport's Hash#as_json and Array#as_json recurse through their members with as_json, never to_json, so a nested Option reached Object#as_json and serialized as its instance variables: {a: Some(5)}.to_json produced {"a":{"value":5}} with no error, and a None became {}. Define as_json on Option::Any and Result::Any to raise SerializeError alongside to_s and to_json. Almost every real serialization path goes through a Hash or an Array, so this is where the guard earns its keep. --- README.md | 4 ++-- lib/errgonomic/option.rb | 9 +++++++++ lib/errgonomic/result.rb | 9 +++++++++ test/rails_test.rb | 12 ++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab03f93..45e88e7 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ in Errgonomic::Option::None end ``` -An unhandled Option refuses to leak into your output: `to_s` and `to_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. +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 `#` to a user. 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": ...}`. `unwrap!` and `expect!` are for tests and consoles, not application code: they raise on `None`, which is exactly the ambiguous failure the type exists to prevent. Application code should always have a combinator or pattern match that handles the `None` branch explicitly; if none fits, that is a gap worth an issue rather than a reason to unwrap. @@ -148,7 +148,7 @@ in Errgonomic::Result::Err, Exception => e end ``` -Like Options, unwrapped Results refuse `to_s` and `to_json`. 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 `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. ### Optional collections diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 457b73b..2f812d0 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -556,6 +556,15 @@ def to_json(*_args) raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option' end + # ActiveSupport's Hash#as_json and Array#as_json recurse through their + # members with as_json rather than to_json, so an Option nested in a + # payload reaches Object#as_json and serializes as its instance + # variables. Refuse there too, and the guard holds wherever an Option + # travels. + def as_json(*_args) + raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option' + end + # pp uses its own object dump unless told otherwise; keep it consistent # with inspect. def pretty_print(pp) diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index 13ac0d9..949ad9c 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -369,6 +369,15 @@ def to_json(*_args) raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result' end + # ActiveSupport's Hash#as_json and Array#as_json recurse through their + # members with as_json rather than to_json, so a Result nested in a + # payload reaches Object#as_json and serializes as its instance + # variables. Refuse there too, and the guard holds wherever a Result + # travels. + def as_json(*_args) + raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Result' + end + # pp uses its own object dump unless told otherwise; keep it consistent # with inspect. def pretty_print(pp) diff --git a/test/rails_test.rb b/test/rails_test.rb index a570279..fa056b7 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -168,6 +168,18 @@ def test_where_with_an_array_of_options refute_includes relation.to_sql, 'NULL' end + # Hash and Array serialization recurses with as_json, never to_json, so + # the refusal has to sit on as_json to survive nesting. + def test_nested_options_and_results_refuse_to_serialize + assert_raises(Errgonomic::SerializeError) { Some(5).as_json } + assert_raises(Errgonomic::SerializeError) { { a: Some(5) }.to_json } + assert_raises(Errgonomic::SerializeError) { { a: None() }.to_json } + assert_raises(Errgonomic::SerializeError) { [Some(5)].to_json } + assert_raises(Errgonomic::SerializeError) { Ok(5).as_json } + assert_raises(Errgonomic::SerializeError) { { a: Err(5) }.to_json } + assert_raises(Errgonomic::SerializeError) { [Ok(5)].to_json } + end + def test_delegate_optional author = Author.create!(name: 'Cixin Liu') book = author.books.create!(title: 'Death\'s End')