April 01, 2015

Contracts: Type Checking for Ruby

Like many Rubyists who read popular coding news, I recently came across the Contracts gem. It caught my eye because it implements some of the features I like in Elixir, but for Ruby.

Type Annotations in Elixir

Both Ruby and Elixir are duck-typed languages. However, Elixir (and its parent, Erlang) allow for static type analysis through annotations. In Elixir, they look like this:

@spec add(integer, integer) :: integer
def add(a, b) do
  a + b
end

Custom types can be defined:

@type uuid :: String.t

@spec find(uuid) :: map
def find(uuid) do
  # implementation here
end

Arguments can be deconstructed via pattern matching in type specs, just like they can in function definitions. Suppose the add/2 function took a tuple instead of two arguments. You could write it like this:

@spec add({integer, integer}) :: integer
def add({a, b}), do: a + b

All together, your Math module might look like this:

defmodule Math do
  @spec add({integer, integer}) :: integer
  def add({a, b}), do: add(a, b)

  @spec add(integer, integer) :: integer
  def add(a, b), do: a + b
end

Math.add(2, 3)   # => 5
Math.add({2, 3}) # => 5
Math.add("hello") # => Error: no matching function definition

In Elixir, these type annotations are analyzed at compile-time for some errors, and the compiled BEAM file can also be run through Dialyzer for static analysis. Together, this two step process can find type errors without even running your code, very similarly to statically typed languages.

Type Annotations in Ruby using Contracts

The Contracts gem is able to do a lot of the same kind of type annotation that Elixir can do. It even adds support for multiple definitions of the same method!

require "contracts"

class Math
  include Contracts

  # Tuple version
  # Ruby doesn't have tuples, so we'll use a two-element array
  Contract [Num, Num] => Num
  def add(numbers)
    add numbers[0], numbers[1]
  end

  # Two arguments version
  Contract Num, Num => Num
  def add(a, b)
    a + b
  end
end

math = Math.new
math.add(1, 2)      # => 3
math.add([1, 2])    # => 3
math.add([1, 2, 3]) # => Error, no matching contract
math.add("hello")   # => Error, no matching contract

It also adds some of the pattern matching goodness that Elixir brings to the table. Suppose I had a function handle_response, that is supposed to deal with a response from a web API. In Elixir, that function might look like this:

def handle_response(:error, body) do
  # Handle error case
end

def handle_response(:success, body) do
  # Handle success case
end

In Ruby without the Contracts gem, it would look like this:

def handle_response(status, body)
  case status
  when :error then # handle error
  when :success then # handle success
  end
end

With the Contracts gem, it looks a lot more like Elixir:

Contract :error, String => Any
def handle_response(status, body)
  # handle error
end

Contract :success, String => Any
def handle_response(status, body)
  # Handle success case
end

Important Differences between Contracts and Elixir

There are a few important differences between the Contracts gem and Elixir’s type annotations.

Feature Contracts Elixir
Compile-time N/A Yes
Static analysis No Yes
Violation errors Yes Yes
Pattern matching Yes Yes
Custom types Yes Yes

Since Ruby isn’t compiled, the contracts are not statically analyzed prior to running your code. You won’t catch a contract error before it occurs, so your tests would still need to exercise most of your code.

Benefits

Why should you want type annotations? I can think of several reasons:

  • They’re optional. You don’t have to use annotations when you want duck-typing. But when types are actually important, you can use them.
  • Type errors do exist. Even if they don’t matter to @dhh.
  • Contracts provide free documentation. Very few functions can handle any input. This means that developers always have to look up what types the function can handle, and if you document that, it will make their lives easier. Documentation can easily be generated off these annotations.
  • Code with greater confidence. You will know that functions won’t run without receiving valid input, cutting down on the number of bad things that could occur.

Overall, I think the Contracts gem looks great, and I’m excited to use it in one of my next Ruby projects.

Additional Resources

P.S. This is not an April Fool’s joke.

Did You Enjoy This Post?