3 New RuboCop Style Cops: SelectByKind, SelectByRange, PartitionInsteadOfDoubleSelect

While upgrading RuboCop in one project, I noticed three new style cops in v1.85.0. I took a close look at them and my recommendation is to enable them all.
Let's start.
The 3 Cops
The cops are:
Style/SelectByKind(PR #14808)Style/SelectByRange(PR #14810)Style/PartitionInsteadOfDoubleSelect(PR #14923)
Each is marked as correctable so if you run this you can automatically apply the changes.
1) Style/SelectByKind
This one rewrites class/module filtering from select/reject to grep/grep_v.
# before
array.select { |x| x.is_a?(Foo) }
array.reject { |x| x.is_a?(Foo) }
# after
array.grep(Foo)
array.grep_v(Foo)
From the PR discussion, two details mattered to me:
The name changed from
SelectByClasstoSelectByKind, because modules are valid too, not only classes.There is historical context:
Enumerable#grephas existed in Ruby for decades, and class matching via===is old Ruby behavior, not a new trick.
I am not saying select { is_a? } is bad code. I am arguing grep(Foo) signals intent faster once the team gets used to it.
Why does grep(Foo) work at all?
Enumerable#grep filters by evaluating pattern === element for each element, where the pattern is always on the left. The left-hand operand decides what === means: Class defines it to call is_a?, Range defines it to call cover?, and Regexp defines it to call match?.
This is why grep(Foo), grep(1..10), and grep(/pattern/) all work the same way. The unifying idea is that grep is a pattern filter, and === is the protocol each pattern type implements.
One thing to watch: if you are using
find { |x| x.is_a?(Foo) }, do not rewrite it togrep(Foo).first. Thefindversion stops at the first match. Thegrepversion builds the entire result array first, then takes the first element. The cop intentionally excludesfind/detectfor exactly this reason.
2) Style/SelectByRange
This one rewrites range checks to grep(range) / grep_v(range).
# before
array.select { |x| (1..10).cover?(x) }
array.reject { |x| (1..10).cover?(x) }
# after
array.grep(1..10)
array.grep_v(1..10)
This PR was explicitly inspired by SelectByKind, and follows the same philosophy: if you are pattern-filtering, use Ruby's pattern-filtering API directly.
For me, this is a consistency win more than anything else. If your codebase already uses grep for regexp or class matching, range matching with grep fits naturally. So if you enable the first cop I shared you should also enable this one.
3) Style/PartitionInsteadOfDoubleSelect
This one is a bit more practical.
# before
positives = array.select { |x| x > 0 }
negatives = array.reject { |x| x > 0 }
# after
positives, negatives = array.partition { |x| x > 0 }
The PR went beyond the obvious case. During review, it added support for:
&:symbolformsmixed forms (
select(&:positive?)+reject { |x| x.positive? })structural negation pairs (
select { expr }withselect { !expr }, and same forreject)
To keep in mind: === Is Not Symmetric
There is one thing I want to make explicit because it is easy to get wrong and the mistake is silent in case you are new to Ruby.
=== is a regular Ruby method called on the left-hand object. So A === B calls A.===(B)
Integer === 42 # calls 42.is_a?(Integer) → true
(1..10) === 5 # calls (1..10).cover?(5) → true
/foo/ === "foobar" # calls /foo/.match?("foobar") → true
If you reverse the order, you are calling === on a plain object: an integer instance, a string instance, a custom class instance. None of those override ===. They fall through to Object#===, which is just self == other.
42 === Integer # 42 == Integer → false (always)
5 === (1..10) # 5 == (1..10) → false (always)
"foobar" === /foo/ # "foobar" == /foo/ → false (always)
The part to pay attention is that Ruby will return false and not raise an error because those objects are implementing the === comparison. It is not just what you might expect it to be. You just get the wrong answer.
Here is the trap in a realistic scenario:
data = [1, "hello", 2, "world", 3]
# correct
data.grep(Integer) # => [1, 2, 3]
data.select { |x| Integer === x } # => [1, 2, 3]
# reversed: looks similar, silently broken
data.select { |x| x === Integer } # => []
The reversed version returns an empty array every time, with no warning, as it is expected cause the object x is defining ===.
You can find out that in these two cases there are two different methods executed:
1.method(:===) # => #<Method: Integer#===(_)>
Integer.method(:===) # => #<Method: #<Class:Integer>(Module)#===(_)>
The method Integer#=== is defined as an alias for == and says this:
Returns whether self is numerically equal to other:
But the method from Module#=== is defined as:
Returns whether other is an instance of self, or is an instance of a subclass of self
There is one edge case where reversal accidentally works by default: when both sides are strings.
"hello" === "hello" # => true (String#== compares values)
"hello" === "world" # => false
This works because String#== compares string content. The moment you switch to a Regexp or a class, the reversal breaks.
/hello/ === "hello world" # => true (Regexp#match?)
"hello world" === /hello/ # => false (String#== sees a non-String, returns false)
grep protects you from this by always calling pattern === element with the pattern on the left. That is part of why reaching for grep is the right call, not just a style preference.
Benchmarks I Ran
I wrote three Ruby scripts to sanity-check behavior and measure runtime:
benchmark_select_by_kind.rbbenchmark_select_by_range.rbbenchmark_partition_instead_of_double_select.rb
Each script first asserts semantic equivalence, then runs repeated benchmarks with benchmark-ips.
ruby benchmark_select_by_kind.rb
ruby benchmark_select_by_range.rb
ruby benchmark_partition_instead_of_double_select.rb
Results on my machine
SelectByKind:grep(UserRecord)reached4.2 i/svs2.6 i/sforselect { is_a? }(about1.62xthroughput).SelectByRange:grep(range)andgrep_v(range)reached1.1 i/svs0.9 i/sforcover?-based filters (about1.17xthroughput).PartitionInsteadOfDoubleSelect:partitionreached1.0 i/svs0.6 i/sforselect + reject(1.68xthroughput), because it walks once.
Should You Enable Them?
My recommendation:
Enable
Style/SelectByKind- because it encodes idiomatic Ruby and keeps intent explicit.Enable
Style/SelectByRange- because it keeps filtering style consistent across regex, class, and range patterns.Enable
Style/PartitionInsteadOfDoubleSelect- because this one is both cleaner and genuinely more efficient.
Since these cops were added in v1.85.0, they may still be in pending status and not enabled by default. Here is the .rubocop.yml snippet to enable them explicitly:
Style/SelectByKind:
Enabled: true
Style/SelectByRange:
Enabled: true
Style/PartitionInsteadOfDoubleSelect:
Enabled: true
If your team is not used to grep / grep_v, there will be a short learning curve. I still think it is worth it.
I might have missed edge cases in unusual enumerables. The performance gains are small in isolation so I think performance should not be the main metric why you should consider these cops. If you saw different benchmark behavior in your app, I would genuinely like to hear it.



