Ruby's Comparable Module — Custom Ordering That Just Works
You’ve probably used sort, min, max, and between? on numbers and strings without thinking much about them. They work because those classes already implement comparison logic Ruby’s core can use. But the moment you have a custom class — a Priority object, a Version, a Weight — and you want to sort a collection of them or compare two instances, you’re on your own. Unless you include Comparable. Then Ruby hands you a full ordering system for the cost of defining one method.
How Comparable Works
Comparable is a module that, once included, gives your class access to <, <=, >, >=, between?, and clamp — all derived from a single method you define: <=>.
The spaceship operator <=> is Ruby’s universal comparison method. It returns -1 if the receiver is less than the argument, 0 if they’re equal, and 1 if the receiver is greater. Return nil if the comparison doesn’t make sense.
Example:
class Version
include Comparable
attr_reader :major, :minor, :patch
def initialize(version_string)
@major, @minor, @patch = version_string.split('.').map(&:to_i)
end
def <=>(other)
return nil unless other.is_a?(Version)
[major, minor, patch] <=> [other.major, other.minor, other.patch]
end
def to_s
"#{major}.#{minor}.#{patch}"
end
end
v1 = Version.new("1.2.3")
v2 = Version.new("1.2.10")
v3 = Version.new("2.0.0")
puts v1 < v2 # => true
puts v3 > v2 # => true
puts v1.between?(Version.new("1.0.0"), Version.new("2.0.0")) # => true
versions = [v3, v1, v2]
puts versions.sort.map(&:to_s) # => ["1.2.3", "1.2.10", "2.0.0"]
puts versions.min # => 1.2.3
puts versions.max # => 2.0.0
One method, <=>, and you get the full comparison toolkit. Array comparison <=> handles the lexicographic version comparison — it compares element by element, moving to the next only when the current values are equal. This is exactly the right behavior for semantic versioning.
A More Real-World Example: Priority
Example:
class Priority
include Comparable
LEVELS = { low: 1, medium: 2, high: 3, critical: 4 }.freeze
attr_reader :level
def initialize(level)
raise ArgumentError, "Unknown priority: #{level}" unless LEVELS.key?(level)
@level = level
end
def <=>(other)
return nil unless other.is_a?(Priority)
LEVELS[level] <=> LEVELS[other.level]
end
def to_s
level.to_s
end
end
tickets = [
{ title: "Login broken", priority: Priority.new(:critical) },
{ title: "Slow load", priority: Priority.new(:medium) },
{ title: "Wrong color", priority: Priority.new(:low) },
{ title: "Data corrupt", priority: Priority.new(:high) }
]
sorted = tickets.sort_by { |t| t[:priority] }.reverse
sorted.each { |t| puts "#{t[:priority]}: #{t[:title]}" }
# => critical: Login broken
# => high: Data corrupt
# => medium: Slow load
# => low: Wrong color
Because Priority includes Comparable, sort_by can use it as a sort key. The .reverse puts highest priority first. No custom comparator logic scattered through your application — the ordering logic lives where it belongs, in the class itself.
The clamp Method
Comparable includes clamp, which restricts a value to a range. Useful for sanitizing inputs or enforcing boundaries:
Example:
class Weight
include Comparable
attr_reader :grams
def initialize(grams)
@grams = grams
end
def <=>(other)
grams <=> other.grams
end
end
payload = Weight.new(750)
min_weight = Weight.new(100)
max_weight = Weight.new(500)
clamped = payload.clamp(min_weight, max_weight)
puts clamped.grams # => 500 — capped at max
This is cleaner than writing the [min, [value, max].min].max pattern manually, and it reads like English.
Mixing With Enumerable
Comparable and Enumerable work together naturally. Any collection of Comparable objects can use min, max, min_by, max_by, sort, and minmax without extra configuration.
Example:
versions = [
Version.new("3.1.0"),
Version.new("2.7.6"),
Version.new("3.0.5"),
Version.new("2.7.8")
]
puts versions.min # => 2.7.6
puts versions.max # => 3.1.0
puts versions.sort.map(&:to_s).inspect
# => ["2.7.6", "2.7.8", "3.0.5", "3.1.0"]
# Group into 2.x and 3.x
grouped = versions.group_by { |v| v.major }
grouped.each { |major, vers| puts "#{major}.x: #{vers.map(&:to_s).join(', ')}" }
Pro-Tip: When implementing
<=>, always handle thenilreturn case explicitly. Ifotheris not the same type — or not a type you can meaningfully compare against — returnnil. Ruby’s sort will raiseArgumentErrorif<=>returnsnilduring a sort operation, which is exactly the right behavior: it surfaces the incompatible comparison rather than silently producing wrong results.
Conclusion
Comparable is one of those Ruby modules that makes you appreciate how well the language is designed. You implement one method, follow one contract, and get a complete, consistent ordering system that integrates with Ruby’s entire collection infrastructure. The next time you have a custom class that has a natural ordering — priorities, versions, weights, scores, tiers — reach for Comparable before writing any comparison logic by hand.
FAQs
Q1: What happens if <=> returns nil during a sort?
Ruby raises ArgumentError: comparison of X with Y failed. This is intentional — incomparable objects shouldn’t sort silently. Ensure your <=> only returns nil for genuinely incomparable inputs and that you don’t mix incomparable types in a sorted collection.
Q2: Do I need to define == separately when using Comparable?
Comparable does provide == based on <=> returning 0, but it doesn’t override eql? or hash. For objects used as hash keys or in sets, define eql? and hash separately to ensure correct behavior.
Q3: Can I include Comparable in a class that already inherits from another class?
Yes. Comparable is a module — it can be included in any class regardless of inheritance. Include it and define <=> in your class; the inherited hierarchy doesn’t affect it.
Q4: What’s the difference between sort and sort_by when using Comparable?
sort calls <=> directly between elements. sort_by extracts a key and sorts by that key — it’s more efficient when the key computation is expensive (it’s computed once per element, not once per comparison). For simple sorts on Comparable objects, sort is fine.
Q5: Can I use Comparable with frozen or immutable objects?
Yes. Comparable only reads attributes via <=> — it doesn’t mutate anything. It works perfectly with frozen objects, Data instances, and any immutable value type.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀