Runtime Type Checking with Pattern Matching in Ruby 3
While Ruby 3 introduced Pattern Matching to handle data structures more expressively, I’ve started using this feature as an elegant “hack” for performing runtime type checks. Let’s explore how this can be applied to validate both primitive and custom types.
A few days ago, I shared these insights on my Mastodon account and saw that it got a good interation, so I and I decided to share a bit about how I’ve been using it.
Pattern Matching
Pattern Matching is a feature that allows you to match a value against a pattern and extract its components. It was introduced in Ruby 2.7 and improved in Ruby 3.0. It’s a powerful feature that allows you to handle data structures more expressively. To better understand, how the pattern works you can check the official documentation. Let’s see a simple example:
You can use the in
operator to match a value against a pattern and extract its components. For example:
case [1, 2, 3]
in [a, b, c]
"Matched: a=#{a}, b=#{b}, c=#{c}"
end
This will output:
"Matched: a=1, b=2, c=3"
You can also use the in
operator and =>
to match a value against a pattern and extract its components in a single line. For example:
fruits = { apple: 100, banana: 200, cherry: 300 }
fruits in { apple: Numeric => amount_of_apples }
# => true
amount_of_apples
# => 100
If don’t match, it will return false
and amount_of_apples
will be nil
.
fruits = { apple: "100", banana: "200", cherry: "300" }
fruits in { orange: Numeric => amount_of_oranges }
# => false
amount_of_oranges
# => nil
Runtime Type Checking
The Ruby is a dynamic language, so it’s not necessary to declare the type of a variable when you define it. However, sometimes it’s necessary to check the type of a variable at runtime. Let’s see the simple example:
def sum(a, b)
a + b
end
sum(1, 2)
# => 3
This will work fine, but if you pass a string, it will raise an error:
sum("1", "2")
# => TypeError (String can't be coerced into Integer)
To ensure that the a
and b
are integers, you can use the pattern matching to check the type of the variables:
def sum(a, b)
a => Integer
b => Integer
a + b
end
sum(1, 2)
# => 3
If the a
or b
is not an integer, it will raise an error:
sum("1", 2)
# => `sum': "1": Numeric === "1" does not return true (NoMatchingPatternError)
sum("1", 2)
Now, you can ensure that the a
and b
are integers. Is this checking efficient? Let’s see other example:
sum(2, 2.5)
# => `sum': 2.5: Integer === 2.5 does not return true (NoMatchingPatternError)
Boom! It will return an error because the b
is a float. It seems to create other problems with this. Let’s see how to resolve this.
Union Types
A simple way to resolve this problem is to use the union types. You can use the |
operator to specify that a variable can be of one of several types. For example:
def sum(a, b)
a => Integer | Float
b => Integer | Float
a + b
end
sum(2, 2.5)
# => 4.5
Now, you can ensure that the a
and b
are integers or floats.
Custom Types
You can also check custom types. For example, you can create a custom type to check if a variable is a positive integer:
PositiveNumber = ->(n) { n.is_a?(Numeric) && n.positive? }
def sum(a, b)
a => PositiveNumber
b => PositiveNumber
a + b
end
sum(2, 3)
# => 5
If the a
or b
is not a positive integer, it will raise an error:
sum(-2, 3)
`sum': -1: #<Proc:0x0000000103f394f0 (irb):1 (lambda)> === -1 does not return true (NoMatchingPatternError)
While it’s more common to check a function’s input parameters, sometimes it’s crucial to ensure the function’s outcome is exactly what we expect. For this, we can also apply a simple rule that confirms if the return is a positive number:
PositiveNumber = ->(n) { n.is_a?(Numeric) && n.positive? }
def sum(a, b)
a => Integer | Float
b => Integer | Float
result = a + b
result => PositiveNumber
result
# Do something with the result
end
# If the `result` is not a positive integer, it will raise an error:
sum(-3, 2)
# => `sum': -1: #<Proc:0x0000000107a38330 (irb):1 (lambda)> === -1 does not return true (NoMatchingPatternError)
# If the `result` is positive, we won't have any problems
sum(-1, 3)
# => 2
Conclusion
Although different form the original intention of Pattern Matching, this technique of type checking can be an elegant, easy, and descriptive solution for checking a type in Ruby. It allows a way to ensure objects are of the correct type, reducing the chance of unexpected errors at runtime. However, it’s important to remember this is an adaptation and less efficient compared to .is_a?
obj.is_a?(Numeric)
It’s also worth noting that there are specialized gems for type validation that might be more suitable for critical applications. Experiment with moderation and always consider the conventions and best practices of the Ruby community.
Thanks @hansmelo for the review. 🙏