cover

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

References