Skip to main content

Command Palette

Search for a command to run...

3 New RuboCop Style Cops: SelectByKind, SelectByRange, PartitionInsteadOfDoubleSelect

Published
7 min read
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:

  1. Style/SelectByKind (PR #14808)

  2. Style/SelectByRange (PR #14810)

  3. 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 SelectByClass to SelectByKind, because modules are valid too, not only classes.

  • There is historical context: Enumerable#grep has 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 to grep(Foo).first. The find version stops at the first match. The grep version builds the entire result array first, then takes the first element. The cop intentionally excludes find/detect for 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:

  • &:symbol forms

  • mixed forms (select(&:positive?) + reject { |x| x.positive? })

  • structural negation pairs (select { expr } with select { !expr }, and same for reject)

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.rb

  • benchmark_select_by_range.rb

  • benchmark_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) reached 4.2 i/s vs 2.6 i/s for select { is_a? } (about 1.62x throughput).

  • SelectByRange: grep(range) and grep_v(range) reached 1.1 i/s vs 0.9 i/s for cover?-based filters (about 1.17x throughput).

  • PartitionInsteadOfDoubleSelect: partition reached 1.0 i/s vs 0.6 i/s for select + reject (1.68x throughput), because it walks once.

Should You Enable Them?

My recommendation:

  1. Enable Style/SelectByKind - because it encodes idiomatic Ruby and keeps intent explicit.

  2. Enable Style/SelectByRange - because it keeps filtering style consistent across regex, class, and range patterns.

  3. 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.

Resources