/ Tags: RUBY 3 / Categories: RUBY

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 the nil return case explicitly. If other is not the same type — or not a type you can meaningfully compare against — return nil. Ruby’s sort will raise ArgumentError if <=> returns nil during 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More