Contract Programming an Elixir approach – Part 1
- Raul Chouza
- 22nd Jun 2022
- 7 min of reading time
This series explores the concepts found in Contract Programming and adapts them to the Erlang and BEAM languages, in general, are surrounded by philosophies like “fail fast”, “defensive programming”, and “offensive programming”, and contract programming can be a nice addition. The series is also available on Github.
You will find a lot of unconventional uses of Elixir. There are probably things you would not try in production, however, through the series, we will share some well-established Elixir libraries that already use contracts very well.
It is an approach to program verification that relies on the successful execution of statements; not that different from what we do with ExUnit when testing:
defmodule Program do
def sum_all(numbers), do: Enum.sum(numbers)
end
ExUnit.start(autorun: false)
defmodule ProgramTest do
use ExUnit.Case
test "Result is the sum of all numbers" do
assert Program.sum_all([-10, -5, 0, 5, 10]) == 0
end
test "Should be able to process ranges" do
assert Program.sum_all(0..10) == 55
end
test "Passed in parameter should only be a list or range" do
assert_raise Protocol.UndefinedError,
~s(protocol Enumerable not implemented for "1 2 3" of type BitString),
fn -> Program.sum_all("1 2 3") end
end
test "All parameters must be of numeric value" do
assert_raise ArithmeticError, ~s(bad argument in arithmetic expression), fn ->
Program.sum_all([["1", "2", "3"]])
end
end
end
ExUnit.run()
....
Finished in 0.00 seconds (0.00s async, 0.00s sync)
4 tests, 0 failures
In the example above, we’re taking Program.sum_all/1 and verifying its behavior by giving it inputs and matching them with the outputs. In a sense, our function becomes a component that we can only inspect from the outside. Contract programming differs in that our assertions get embedded inside the components of our system. Let’s try to use the assert keyword within the program:
defmodule VerifiedProgram do
use ExUnit.Case
def sum_all(numbers) do
assert is_list(numbers) || is_struct(numbers, Range),
"Passed in parameter must be a list or range"
result =
Enum.reduce(numbers, 0, fn number, accumulator ->
assert is_number(number), "Element #{inspect(number)} is not a number"
accumulator + number
end)
assert is_number(result), "Result didn't return a number got #{inspect(result)}"
result
end
end
Our solution became a bit more verbose, but hopefully, we’re now able to extract the error points through evaluation:
VerifiedProgram.sum_all("1 2 3")
** (ExUnit.AssertionError)
Passed in parameter must be a list or range
VerifiedProgram.sum_all(["1", "2", "3"])
** (ExUnit.AssertionError)
Element "1" is not a number
This style of verification shifts the focus. Instead of just checking input/output, we’re now explicitly limiting the function reach. When something unexpected happens, we stop the program entirely to try to give a reasonable error.
This is how the concept of “contracts” works in a very basic sense.
Having contracts in our codebase doesn’t mean that we can stop testing. We should still write them and maybe even reduce the scope of our checks:
defmodule VerifiedProgramTest do
use ExUnit.Case
test "Result is the sum of all numbers" do
assert VerifiedProgram.sum_all(0..10) == 55
assert VerifiedProgram.sum_all([-10, -5, 0, 5, 10]) == 0
assert VerifiedProgram.sum_all([1.11, 2.22, 3.33]) == 6.66
end
end
ExUnit.run()
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
By using our functions in runtime or test-time we can re-align the expectations of our system components if requirements change:
# Now we expect this to work
VerifiedProgram.sum_all("1 2 3 4")
** (ExUnit.AssertionError)
Passed in parameter must be a list or range
We also need to make the changes required for it to happen. In this case, we need to expand our domain to also include stringified numbers, separated by a space.
As seen in the examples above, there’s nothing stopping us from trying the assert keyword. It is a creative way to verify our system components. However, I feel that the failures are designed in such a way as to be used in a test environment, not necessarily at runtime.
From the docs: “In general, a developer will want to use the general assert macro in tests. This macro introspects your code and provides good reporting whenever there is a failure.“Thankfully for us, in Elixir, we have a more primitive mechanism in which we can assert data in an effective way: pattern matching. I would like to explore this more in-depth in the second installment of this contract series.
About the author
While on a desk Raúl spends time as an elixir programmer, still distilling some thoughts on the topic of “contract programming”; otherwise he’s a recent dad, enjoys simulation games and trying out traditional food. He operates from Tijuana, México from where he was born and lives.
Pawel Chrząszcz introduces MongooseIM 6.3.0 with Prometheus monitoring and CockroachDB support for greater scalability and flexibility.
Here's how machine learning drives business efficiency, from customer insights to fraud detection, powering smarter, faster decisions.
Phuong Van explores Phoenix LiveView implementation, covering data migration, UI development, and team collaboration from concept to production.