Seeking Simple Satisfaction:

An Exploration Using Elixir and Dialyzer

On the way back to Boston from the wonderful EmpEx conference, I felt an itch to code. Not an itch to write something grand or complex. Quite the opposite, I felt the need to write something simple and satisfying. It’s possible Cameron Price’s talk about refining your tools through small practices was still tickling the back of my brain.

I needed an exercise that was well defined and flowed from start to finish. I settled on writing a Roman numeral generator. It’s an old and fairly well known kata, especially among TDD advocates. I hadn’t done it in some time, but I remembered it elegantly fitting the TDD style. It seemed the perfect fit for my craving.

Setup

Given that all applications I start in Elixir start the same way, this first part is just going to be a short tutorial so you can follow along. We’ll get back to the narrative after.

The default install of Elixir contains Mix, a task runner used for most project commands, such as “new.” New is a generator command that creates a default project directory and file structure. No need to stress about best community practices for structuring your application. Just run “new” and initialize git.

$> mix new roman_numeral
$> cd roman_numeral
$> git init
$> git add .
$> git commit -m 'Init'

Generally this is a good place to just get started, but there’s one last bit that I add to all of my personal projects. They’ll come up again later in the narrative. Dialyzer and Credo are code analysis tools. I suggest adding them to projects before starting to write. You’ll be surprised in the ways they help you. Add the following to the generated mix.exs file.

{:credo, “~> 0.3.10”, only: [:dev, :test]},
{:dialyxir, “~> 0.3.3”, only: [:dev, :test]},

Now that everything is set up, run a pair of Mix commands to verify the project is working.

$> mix deps.get
$> mix test
Finished in 0.03 seconds (0.03s on load, 0.00s on tests)
1 test, 0 failures

The first grabs the dependencies we just added. The second compiles any files that need compilation and then runs the default test that was generated with the project.

Now back to our story.

Starting with nothing

I wanted the process of writing this application to be as simple as the application itself. Every step should be obvious and flow from the previous step. Write a test. Make it pass. Repeat.

I started by differing slightly from the kata. There’s no simple numeral for zero, but out of years of habit, I want to start counting at zero. I settled on using “nvlla” as the representation for zero and wrote my first test.

Simple enough. If we create a numeral from zero, the returned numeral should be “nvlla.” The implementation that follows should be equally simple.

Yep. Just pattern match on zero and return the hard coded string. The implementation didn’t really match the long term intent so I added in a typespec to convey that intent. The @spec attribute says that this function will take any integer and will return a string. Let’s see what our type checking tool thinks of this.

$> mix dialyzer
Starting Dialyzer
...
roman_numeral.ex:9: Type specification 'Elixir.RomanNumeral':create(integer()) -> 'Elixir.String':t() is a supertype of the success typing: 'Elixir.RomanNumeral':create(0) -> <<_:40>>

Ha. Yeah. That’s fair. I told it that the function could take any integer. After a quick look at my code, Dialyzer knows I lied to it. It tells me that success typing shows this function only ever accepts the number zero. I can actually use this as a measure of completeness. Once Dialyzer agrees that the function can actually accept any integer, I’ll know I’m covering the full problem space. That, in addition to tests verifying the functionality, will show completeness of the problem.

Covering our bases

Now that I’ve got the zeroth case covered, I’m going to add in all of the base cases. Passing in one should return “I.” Passing in five should return “V.” Etc.

Again, we’re staying simple. Only taking the necessary step to move forward. Each Roman numeral maps directly to a number. Test only those numbers.

And again, the implementation is simple. We’re only testing specific numbers, so we only need to implement specific numbers. It’s perfectly fine for all of this to be hard coded right now. Let’s check in on dialyzer again.

$> mix dialyzer
...
roman_numeral.ex:9: Type specification 'Elixir.RomanNumeral':create(integer()) -> 'Elixir.String':t() is a supertype of the success typing: 'Elixir.RomanNumeral':create(0 | 1 | 5 | 10 | 50 | 100 | 500 | 1000) -> <<_:8,_:_*32>>

It shows all the numbers the function does cover, but I’m a long way from supporting the full integer space. If I’m ever going to get there, I’ll have to move beyond hard coding the inputs.

Adding complexity

I’ve covered all of my base cases now, but Roman numerals compound. Its time to add in testing for numbers that become combinations of letters.

Just because I’m adding complexity doesn’t mean I have to do it all at once. This is an exercise in simplicity after all.

Even a small jump in complexity can result in a substantial refactoring. I have to keep track of the current string as I’m adding letters to it. This requires adding in a recursive helper function to step through each addition. For the case of two, I call do_create(2, “”). This in turn adds “I” to the string, decrements the number, and recalls do_create. When the number reaches zero, the resulting string is returned.

So far I’ve only tested numerals with more than one “I” in them. Now I need to extend my test suite to cover this additive property for all of the base cases. I add in a series of tests each testing a number between the base cases, e.g. 7, 13, 77, etc.

The implementation to cover all of these simple compound numbers follows the same pattern for compounded “I”s.

It’s pretty simple to look at each line and see exactly which case it’s covering. You can even imagine the call recursively flowing up through each case as the numeral gets built up. There’s a bit of unnecessary repetition here that could be cleaned up with a macro, but I’m going for simplicity here not terseness.

The tricky bits

I’ve finally run to the end of all the simple compound cases. Roman numerals have an irritating set of subtractive cases that are what make this exercise interesting. Four becomes “IV” instead of “IIII.” Fourteen becomes “XIV” instead of “XIIII.”

Since these are all exceptions to the standard rule, I throw all of them into a single test case with multiple assertions. I also add in an extra case to make sure the exceptions are getting compounded correctly.

Each of the exceptions behaves identically to one of the original base cases. So much so that they can be added in using the same pattern as all the other cases we’re covering. Now all numbers fall into a specific case and then flow upward until they finish. The solution feels elegant even if a bit repetitive.

Clean up

The exercise is complete. I can now convert any number to its appropriate Roman numeral. However, the path here has created a rather large test file. Not all of these tests are necessary now that I’ve completed the implementations. It’s always a good idea to clean up the bits of TDD that were only created for the TDD process. Reducing down to a smaller set of tests means there is less code to maintain and less coupling between tests and implementation.

This is the final test file. The first test covers all of the original base numbers. The second test covers each of the exceptions. The third test checks a couple of the exceptions to make sure they compound correctly. This covers all of our use cases.

Giving in

I mentioned earlier that Dialyzer could be used to verify that our implementation covers the full problem space. Let’s check back in and see what it has to say now.

$> mix dialyzer
...
roman_numeral.ex:9: Type specification 'Elixir.RomanNumeral':create(integer()) -> 'Elixir.String':t() is a supertype of the success typing: 'Elixir.RomanNumeral':create(non_neg_integer()) -> binary()

Oh for … . Fine. Yes. We’re only using this function with non negative numbers. This is because there aren’t negative Roman numerals. I’ll just make one last change so that the typespec and guard clauses respect this reality.

The final code is up on github. I found the process of implementing this kata extremely relaxing. The fact that Dialyzer found a mistake in even this small of an implementation was a wonderful surprise and really illustrates the utility of the tool.

Thanks for reading.