What are Ruby Contracts?
The gem author defines them as follows:
Contracts which let you clearly and beautifully express how your code behaves, while freeing you from writing tons of boilerplate, defensive code. You can think of contracts as assert on steroids.
In other words, they are contracts which allow you to define what is the correct input and output for a piece of code, and how your code should behave depending on certain conditions. A good way to summarize all of this would be: contracts which allow you to “type” your code.
So, how does it work?
First, we need to install the gem by using the following command:
gem install contracts
After installing the gem, we can now use it in a similar way to how we use a Java or Python decorator
require 'contracts'
class Example
include Contracts::Core
include Contracts::Builtin
Contract Num => Num
def double(x)
x * 2
end
end
Go step by step. First, we need to include the gem core and the Builtin module:
include Contracts::Core
include Contracts::Builtin
The built-in module, as the name suggests includes many ready-to-use common contracts, just as Num in our previous example.
You can see a list of built-in contracts here
After including the required modules we can define our contracts, where the first part is the expected input and the next par =>
is the expected output:
In => Out
Contract Num => Num
def double(x)
x * 2
end
If we name our double method with a number, we are going to obtain the expected result, but if we try to name it with a string value (just to mention an example), we should see a detailed error message, so let’s try it out. I will add this at the bottom part as an example.rb
puts Example.new.double(2)
puts Example.new.double('2')
An error was thrown with a description very similar to RSpec errors:
ParamContractError (Contract violation for argument 1 of 1:
Expected: Num,
Actual: "2"
Value guarded in: Object::double
With Contract: Num => Num
At: file.rb:5 )
Now, there are a lot of ways to solve this, but, what happens if you need your method to get strings too? A possible solution could be to validate the input and cast it:
def double(x)
x = x.to_i unless x.is_a?(FixNum)
x * 2
end
This might not be the best solution, but you get the point. The problem with the code above is that a really simple method can get way more complex and may be unreadable if we need to add (for example) more types. In these kinds of cases I think contracts are very useful, since they allow us to solve this issue by maintaining our methods as simple as possible. Let’s see how we could solve the problem above, but this time using contracts:
Contract Num => Num
def double(x)
x * 2
end
Contract String => Num
def double(x)
double(x.to_i)
end
As you can see, we only needed to add a new contract for the string type, but it gets even better. Now we can use the original method, but with the certainty that the input is a string, this way we can make sure that we will send a number to the original method, and that is all. It works in the same way but without modifying our original method and without adding validations, conversions, etc.
Finally, let’s see a more “real-life” example. Imagine that you have an endpoint in your rails app that processes a HttpResponse from an API, and you need to check if the request succeeds (200) or fails (4xx). For sample purposes, we have a method called process_response
which receives the status code and the response when we want to get a success message while the status is equal to 200, but in other cases, we might want to return an error, so we could do something very simple to handle this scenario:
class Example
def process(status, response)
return "Error 4xx", unless status === 200
#do_something with the response
"OK"
end
end
This really works, but what happens when we need to handle new error codes? Well, we could add more conditions or even refactor all of this in order to use the benefits of metaprogramming. There are many solutions, some of which could unnecessarily increase the complexity of our code.
We can quickly solve this situation by using contracts without changing the current behavior of our code:
class Example
include Contracts::Core
include Contracts::Builtin
Contract 200, HashOf[String, String] => Any
def process(status, response)
#do_something with the response
"OK"
end
Contract lambda {|n| n >= 400 && n < 500 }, HashOf[Symbol, Or[String,Num]] => String
def process(status, response)
"Not Found"
end
Contract lambda {|n| n >= 500 }, Maybe[HashOf[Symbol => Any]] => Maybe[String]
def process(status, response)
"Server Error"
end
Contract Num, Not[Hash] => String
def process
"Default Error"
end
end
class Example
def process(status, response)
return unless response.is_a? Hash # Only validates it is a Hash but not the key and value types
return "Error 4xx" if status >= 400 && status < 500 return "Error 500" if status >= 500
if status == 200
#do_something with the response
"OK"
else
"Default Error"
end
end
end
As you can see, the code is more verbose while using contracts, but the complexity of the process method is also lower.
In conclusion; contracts are not always the answer, but they can be really useful when we prefer to maintain our original logic and handle specific cases with different logic. In the end, it is your call if you want to use them, but if you choose so, be sure to analyze your use cases, and if they do not represent an advantage to you, use them in other cases and try to find another alternative.