๐Ÿ˜ฒ Elixir’s Public Types for GenServer/Supervisor

This week I had received a code review from a teammate to add a @spec to a start_link function I wrote for a GenServer. My start_link function will take a keyword list, try to get the values :first_name and :last_name with defaults, and then call GenServer.start_link/2 with __MODULE__ as the module and those values as the initial state. This GenServer looks like this:

defmodule JoelServer do
  use GenServer
  
  def start_link(opts \\ []) do
    first_name = Keyword.get(opts, :first_name, "Joel")
    last_name = Keyword.get(opts, :last_name, "Abshier")
    initial_state = %{
      first_name: first_name,
      last_name: last_name  
    }
    
    GenServer.start_link(__MODULE__, initial_state, opts)
  end
  
  def init(state) do
    {:ok, state}
  end
end

What I would like to do is add a @spec to describe how to use this function. Luckily, in Elixir we have Dialyzer to make this easy. Dialyzer is a static analysis tool within Erlang/Elixir. One feature of Dialyzer is to suggest @spec annotations for a function. Let’s see what it thinks for this:

@spec start_link(keyword) :: :ignore | {:error, any} | {:ok, pid}
def start_link(opts \\ []) do
  ...
end

While that’s correct, a more specific @spec is valuable to us. Let’s look at how we can do that.

๐Ÿ“จ Arguments / Input Type

Let’s take a look at the keyword list we’re passing in. It technically doesn’t matter to us which options are passed in, in addition to :first_name and :last_name, however if the spec were to define those specific keywords, we could catch any issues and/or typos while writing the code. Let’s define a private type that lists our keywords. First, add the typep definition and then update the @spec accordingly.

@typep start_opt :: {:first_name, String.t()} | {:last_name, String.t()}
@spec start_link(opts :: [start_opt]) :: :ignore | {:error, any} | {:ok, pid}
def start_link(opts \\ []) do
  ...
end

Now if you try to call JoelServer.start_link(title: "Mr.") you’ll get a Dialyxir warning saying something like this:

([{title, <<77,114,46,32>>}]) breaks the contract (options::[start_opt()])

That’s pretty cool! Now the next time someone on my team has to use this GenServer, they’ll be told exactly how to use it while calling it. But what about the return type?

๐Ÿ“ฌ Output / Return Type

Now for the reason you’re here. A co-worker pointed me toward a type in the GenServer docs on_start/0. We can use this to replace the list of return types at :ignore | {:error, any} | {:ok, pid}. This lets us define our start_link with the return type as Elixir’s GenServer.start_link.

@spec start_link(opts :: [start_opt]) :: GenServer.on_start()

I love this, rather than managing our own list of types for each GenServer I define. If you’re working with a Supervisor, we are also given Supervisor.on_start/0. If you want to allow any options allowed by a GenServer or Supervisor, you’ll also find options/0, option/0, init_option/0, and so on.

๐Ÿฐ That’s all the GenServer we have today, folks

Anyhow, this might be pretty basic, if you’re familiar with Elixir. But I thought it was exciting to be told about these and wanted to share it. A lot of Elixir packages, especially the core library, have excellent hexdocs, so I’m going to make a habit of paying closer attention to the @specs on them for when I can take advantage of types like this.

I’ll probably be writing some more shorter posts like this. It’s a bit basic, but I’ll just make a Today I Learned category here on the blog, so you can check those out.

If you have any short tips like this I’d love to hear about them. I’m new to the language, so I’d love to learn! Thanks for reading! ๐Ÿงช

– Joel Abshier