/ Tags: RUBY 3 / Categories: RUBY

Pattern Matching in Ruby 3

Pattern matching landed in Ruby 2.7 as experimental and graduated to stable in Ruby 3.0. Most Ruby developers have heard of it; far fewer use it confidently in production code. This post cuts through the noise — pattern matching isn’t just cleaner case syntax, it’s a fundamentally different way to express conditional logic that makes complex data transformations dramatically easier to read and maintain. By the end, you’ll have a toolset you can reach for immediately.

What Pattern Matching Actually Is


Forget what you know from case/when. Pattern matching is about structure, not equality. You’re not asking “is this value X?” — you’re asking “does this data have the shape I expect?”

Example:

case response
in { status: 200, body: { user: { id: Integer => id } } }
  puts "Got user #{id}"
in { status: 404 }
  puts "Not found"
in { status: (500..) }
  puts "Server error"
end

That single case block does what previously needed a chain of guard clauses, .dig calls, and nil checks. That’s the real value proposition — not fewer lines, but code where the shape of your intent is visible at a glance.

The Four Pattern Types Worth Knowing


1. Value Patterns

Match against a literal, a class, or a range. The => name syntax captures the matched value into a variable.

Example:

case status_code
in 200
  :ok
in 404
  :not_found
in Integer => code if code >= 500
  :server_error
end
2. Array Patterns

Match against positional structure. Splat works as you’d expect.

Example:

case [event_type, payload]
in ["user.created", { email: String => email }]
  send_welcome_email(email)
in ["order.completed", { total: (100..) => total }]
  flag_high_value_order(total)
in [String => unknown, *]
  Rails.logger.warn("Unhandled event: #{unknown}")
end
3. Hash Patterns

Most useful in API and service layer work. Hash patterns do partial matching by default — extra keys are ignored unless you add **nil to enforce exact shape.

Example:

case api_response
in { error: String => message }
  handle_error(message)
in { data: { items: Array => items } } if items.any?
  process_items(items)
in { data: { items: [] } }
  :empty_result
end
4. Find Pattern

Searches through an array for a matching element using [*, pattern, *]. Rare in application code, invaluable when processing event streams or log entries.

Example:

case audit_log
in [*, { level: :error, code: Integer => code, message: String => msg }, *]
  notify_ops("Error #{code}: #{msg}")
end

The Pin Operator: When You Need Equality, Not Binding


Variable binding is powerful, but sometimes you want to match against an existing variable’s value — not rebind it. That’s what ^ (pin) does.

Example:

expected_id = current_user.id

case record
in { user_id: ^expected_id }
  :authorized
in { user_id: Integer }
  :unauthorized
end

Without the pin, expected_id would be rebound to record[:user_id] inside the match — almost certainly not what you want.

One-Line Pattern Matching in Ruby 3.x


Ruby 3.0 introduced => and in as standalone expressions for single-pattern assertions.

Deconstruct into locals:

{ name: "Rajan", role: :admin } => { name:, role: }
puts name  # => "Rajan"
puts role  # => :admin

Boolean shape check:

if response in { status: 200, body: Hash }
  proceed_with_response(response)
end

This is particularly useful in RSpec and conditional service logic where you want to assert shape without a full case block.

Production Patterns Worth Stealing


Service Response Normalisation

Pattern matching shines when normalising responses from inconsistent external APIs — the kind where sometimes error is a string, sometimes it’s a nested object, and sometimes the whole key is absent.

Example:

def parse_payment_response(response)
  case response
  in { success: true, transaction_id: String => txn_id }
    { ok: true, txn_id: }
  in { success: false, error: { code: Integer => code, message: String => msg } }
    { ok: false, error: "#{code}: #{msg}" }
  in { timeout: true }
    { ok: false, error: "Request timed out" }
  in { success: false }
    { ok: false, error: "Unknown payment failure" }
  end
end

Every branch is readable. No rescue chains. No defensive .dig gymnastics.

Event-Driven Dispatching

In systems using domain events or message queues, pattern matching provides a declarative dispatcher that scales gracefully as event types grow.

Example:

def handle_event(event)
  case event
  in { type: "UserRegistered", payload: { id: Integer => id, email: String } }
    UserMailer.welcome(id).deliver_later
  in { type: "SubscriptionExpired", payload: { user_id: Integer => uid } }
    User.find(uid).downgrade_to_free!
  in { type: "InvoicePaid", payload: { amount: (1000..) => amount } }
    flag_high_value_invoice(amount)
  in { type: String => unknown_type }
    Rails.logger.warn("Unhandled event: #{unknown_type}")
  end
end

Pro-Tip: Deploy pattern matching at system boundaries — API parsing, event handling, external service responses. Inside tight domain logic, plain Ruby conditionals often remain clearer. Pattern matching earns its weight when incoming data shape is uncertain or variable.

Custom Classes and deconstruct


Pattern matching works with any Ruby class that implements deconstruct (for array patterns) or deconstruct_keys (for hash patterns).

Example:

class Money
  attr_reader :amount, :currency

  def initialize(amount, currency)
    @amount = amount
    @currency = currency
  end

  def deconstruct_keys(keys)
    { amount:, currency: }
  end
end

case payment_amount
in Money[amount: (0..), currency: "USD"]
  process_usd_payment(payment_amount)
in Money[currency: String => cur]
  raise "Unsupported currency: #{cur}"
end

This is particularly powerful in domain models where you want the pattern match to feel like querying the domain rather than poking at internal state.

Conclusion


Pattern matching is one of those features that takes an afternoon to learn but fundamentally changes how you model conditional logic. It’s not about fewer lines — it’s about code where the structure of your intent is visible at a glance. Start at your API boundaries and event handlers, where data shape is uncertain. Once you see it eliminate defensive nil-checking and brittle .dig chains in real code, the instinct to reach for it will become natural.

FAQs


Q1: Is Ruby pattern matching stable enough for production?
Yes. It’s been fully stable since Ruby 3.0 and widely adopted. The find pattern stabilised in Ruby 3.2.

Q2: Does pattern matching work with custom classes?
Yes. Implement deconstruct for array patterns and deconstruct_keys for hash patterns on any Ruby class to make it pattern-matchable.

Q3: What happens when no pattern matches?
Ruby raises NoMatchingPatternError. Add else or in _ as a catch-all to handle unexpected shapes gracefully.

Q4: How does pattern matching perform compared to traditional conditionals?
Benchmarks show negligible overhead for typical application code. The readability gains far outweigh any micro-performance cost in service layers or API handling.

Q5: Can I use pattern matching inside RSpec?
Yes. The in expression form is useful for asserting response shape in request specs without coupling tests to exact values — particularly when testing external API integrations.

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