This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
When a Single Type Assumption Broke Newsletter Tag Filtering in Ruby
A bug doesn't always need hundreds of lines of code to cause a real problem.
Sometimes, one assumption is enough.
In this case, the assumption was simple:
"Every tag is a String."
That assumption lived inside a Ruby filtering method in the open-source RubyEvents project.
It worked perfectly — until a tag wasn't a String.
That small mismatch was enough to turn a normal user interaction into an error.
This is the story of how the bug was investigated, what was actually going wrong, and how a small change made the filtering logic safer and clearer.
The Bug
The issue was reported as:
"Cannot Click tags on Newsletter"
The behavior was straightforward:
- Open a newsletter.
- Click one of its tags.
- Instead of getting the filtered announcements, the application raises an error.
The issue was tracked as #1836.
At first glance, this sounds like it could be a routing or controller problem.
It wasn't.
The failure was deeper in the tag-filtering logic.
Finding the Assumption
The filtering logic lived in
Announcement::Collection#by_tag
The original implementation was:
def by_tag(tag)
Collection.new(
select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
)
end
The intent is easy to understand:
- take all tags from an announcement
- convert them to lowercase
- compare them with the requested tag
- return matching announcements
For normal String values, this works perfectly.
For example:
["Ruby", "Rails"].map(&:downcase)
=> ["ruby", "rails"]
But there is an important assumption hidden inside:
map(&:downcase)
Every element must respond to downcase.
That's not necessarily true.
Imagine the collection contains:
["Ruby", 123]
Calling:
["Ruby", 123].map(&:downcase)
eventually tries to execute:
123.downcase
And integers don't have a downcase method.
The result is a NoMethodError.
So the real problem wasn't the newsletter link itself.
The newsletter was simply the path that exposed an unsafe assumption in the tag-filtering code.
The Interesting Part: Fixing the Bug Without Changing the Behavior
There were several ways this could have been "fixed."
For example, we could have assumed that all tags should always be Strings and modified the data at the source.
But that would increase the scope of the change.
The filtering method already had a clear responsibility:
Find announcements whose tags match the requested tag, case-insensitively.
So instead of changing the data source, I wanted to make the comparison resilient to the values it actually received.
The implementation became:
def by_tag(tag)
Collection.new(
select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
)
end
There are two important changes here.
- to_s Creates a Safe Comparison Boundary
Instead of assuming:
tag_value.downcase
we explicitly convert the value:
tag_value.to_s
Now a String remains a String:
"Ruby".to_s
# => "Ruby"
And a non-String value becomes safely comparable:
123.to_s
# => "123"
The filtering code no longer crashes simply because a tag value isn't already a String.
This is a small example of something I find important when working with Ruby:
Don't make an assumption about the type of an object if you don't actually need that assumption.
- casecmp? Expresses the Actual Requirement
The original implementation lowercased both sides:
tag_value.downcase == tag.downcase
But the actual requirement isn't:
Convert everything to lowercase.
The requirement is:
Compare these values without considering case.
Ruby provides exactly that operation:
casecmp?
So the comparison becomes:
tag_value.to_s.casecmp?(tag)
This makes the intent clearer.
We're not transforming the data just to compare it.
We're performing a case-insensitive comparison.
Why any? Instead of map?
This was another small but meaningful improvement.
The old implementation transformed every tag:
a.tags.map(&:downcase).include?(tag.downcase)
But we don't actually need a new array.
We only need to answer one question:
Does at least one tag match?
That's exactly what any? communicates:
a.tags.any? { |tag_value| ... }
It also allows Ruby to stop checking once a match is found.
So the new implementation isn't simply more defensive.
It's also closer to the actual intent of the operation.
Before vs After
Before
def by_tag(tag)
Collection.new(
select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
)
end
The hidden assumption:
Every tag
↓
must respond to #downcase
If one doesn't:
NoMethodError
↓
request fails
↓
user cannot follow the newsletter tag
After
def by_tag(tag)
Collection.new(
select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
)
end
Now:
Tag value
↓
convert safely to String
↓
case-insensitive comparison
↓
match / no match
The existing filtering behavior remains intact while the unsafe type assumption is removed.
Why This Bug Was Easy to Miss
This is what I found most interesting about the issue.
The original code isn't obviously bad.
For a dataset containing only Strings, this is perfectly reasonable Ruby:
a.tags.map(&:downcase)
The problem only appears when the runtime data doesn't match the assumption made by the implementation.
That's a common class of bugs:
Code assumption
↓
"this value will always be a String"
↓
Works for normal data
↓
Unexpected value enters the system
↓
Runtime failure
The lesson isn't:
"Never use downcase."
It's:
Know where your assumptions about data types are coming from.
If a method operates on data that can contain different types, the boundary where those values are consumed should be resilient.
The Investigation
What made this issue useful as a debugging exercise was that the final code change was small.
The investigation was the interesting part.
Instead of only looking at the error message, I traced the behavior back to the collection filtering logic.
That led to a few questions:
- What type can each tag actually contain?
- Why does downcase fail?
- Do we really need to transform every tag?
- Can the comparison itself be made type-safe?
- Can the existing behavior be preserved without changing the surrounding code?
Once those questions were answered, the fix became much simpler.
The problem wasn't complicated business logic.
It was an unsafe assumption about the data.
The Final Change
The actual change was intentionally small:
- Collection.new(select { |a| a.tags.map(&:downcase).include?(tag.downcase) })
+ Collection.new(select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } })
One line changed.
But that one line removed the assumption that every tag value was already a String.
I also fixed a linting issue in a follow-up commit.
The pull request was reviewed by the RubyEvents maintainer, passed all checks, and was merged into the project's main branch.
Issue
1836 — Cannot Click tags on Newsletter
Pull Request
1847 — Fix tag filtering for non-string tags
What I'm Proud Of
The final diff is tiny.
That's actually what I like about this fix.
There was no need to rewrite the filtering system or introduce another abstraction.
The existing behavior was already correct for valid String values.
The problem was the assumption around those values.
So instead of changing the entire flow, the fix:
- keeps the existing behavior
- makes the comparison type-safe
- avoids unnecessary array allocation
- expresses the intent with any?
- uses casecmp? for case-insensitive comparison
- keeps the change isolated to the filtering method
Small change, focused responsibility.
What I Learned
- Small bugs can expose bigger assumptions
The code wasn't complicated.
The assumption behind the code was the real problem.
Whenever I see code such as:
items.map(&:some_method)
I now ask myself:
Do I know for certain that every item responds to this method?
That question becomes particularly important at boundaries where data may come from different sources.
- Fix the behavior, not just the exception
It would have been easy to focus only on preventing the NoMethodError.
But a good fix should do more:
- preserve existing behavior
- make the comparison safe
- make the intent clearer
- avoid unnecessary transformations
The final implementation does all four.
- Express the question you're actually asking
Compare:
map(...).include?(...)
with:
any? { ... }
The second version tells the reader what we're actually asking.
We're not interested in producing another collection.
We're asking whether any tag matches.
That makes the code easier to reason about and maintain.
- Open source debugging is different
One thing I enjoy about contributing to open source is that you get to work with code outside the assumptions of your own applications.
You don't necessarily know every historical decision behind a method.
You don't know every shape of data that has passed through it.
And you don't get to rewrite the whole system just because you find one imperfect assumption.
You have to understand the existing behavior, make the smallest responsible change, and verify that the fix doesn't break what was already working.
That's what made this issue interesting to me.
The final diff was tiny.
The reasoning behind it was not.
The Takeaway
A bug doesn't always announce itself with a complicated stack trace or a thousand-line fix.
Sometimes it's hidden inside a single assumption:
"Every tag is a String."
When that assumption stopped being true, clicking a newsletter tag stopped working.
The fix was to make the comparison type-safe, preserve case-insensitive matching, and express the filtering intent more directly.
One line changed. One user-facing failure removed. One more reminder that robust software is often about handling the values we didn't expect.
Related Code
Issue:
1836 — Cannot Click tags on Newsletter
Pull Request:
1847 — Fix tag filtering for non-string tags












