Elixir is a strong dynamic language : it checks types at run time, enabling some of its most powerful features like pattern matching and macros. The “strong” part tells us that type conversion needs to be explicit, unlike JavaScript, which aims to always do “something” with your code, and happily converts between types.
These properties make Elixir a flexible and productive language, but open the possibility for bugs such as passing the wrong type to a function during run time, crashing the process. The likelihood of such bugs increases as a project’s code base grows.
Many of these bugs can be caught by using static analysis - checking as many assumptions as possible before executing the code. Dialyzer is a great tool for this, using Elixir and Erlang’s built in type system to catch type errors and unreachable code. A welcome side effect of using dialyzer is that it encourages extensive use of the type system available in Elixir, increasing readability and maintainability.
A robust Elixir or Erlang codebase can add Dialyzer in its CI pipeline to increase confidence in code changes and prevent regressions. At Massdriver we host our code on GitHub and make extensive use of GitHub Actions for CI. In this post I’ll show you how to set up Dialyzer in a GitHub Action while avoiding some common mistakes..
Time is Money
The example GitHub Action on the dialyxir README is a good starting point:
This is a good start, but depending on the size of your code base and dependencies, the “Create PLTs” step which builds the Persistent Lookup Tables - the static analysis output - can take a long time. For the main Massdriver application, this step can take over 10 minutes for a complete rebuild! But if the next step, Run dialyzer
, fails, the cache is not saved, so the next run will have to rebuild the PLT from scratch - again!
Fun with Caches
By default, the GitHub Cache action will only save the cache if all steps in the job succeed. But since actions/cache@v3
we can separate the all-in-one action into actions/cache/restore@v3
to restore the PLTs, build them if there was no cache hit, and then finally use actions/cache/save@v3
to save the PLTs even if the Run dialyzer
step fails. This way, if a commit that fails mix dialyzer
is pushed (which happens to me all the time), the subsequent fix will complete CI much faster.
We haven’t yet fixed our Dialyzer bug, but we saved the PLTs to the cache even though Run dialyzer
failed!
Now let’s push a fix, and see how long it takes to run Dialyzer again:
A fast CI shortens feedback loops and enables developers to move faster. Every minute saved on CI is a minute saved every time a dev pushes a commit. Build time optimization is often overlooked, but can have an outsized impact on developer productivity.