Add a Little Safety to your Elixir Structs with TypedStruct

I’m working on an Elixir app at the moment and really enjoying it, but some of my recent dabbling in the type-safe worlds of Elm and Crystal have left me desiring a bit more structure in my code. The app I’m building involves a multi-step data transformation and so I’ve a data structure to properly represent this process. But since Elixir is a dynamically typed language, you can’t, for example, have a non-nillable field in a struct. The Elixir/Erlang ecosystem does, however, have a type-checking syntax called Type Specs, along with a tool, Dialyzer, that will find a fair number of errors at compile-time.

But I found that using defstruct with type specs to be fairly verbose and unintuitive. For example, let’s say you want a “Person” struct with the following attributes:

  • Name - string (non-nillable)
  • Age - positive integer or nil
  • Happy? - boolean (default to true)
  • Phone - string or nil

Normally you’d do the following:

defmodule Person do

  @enforce_keys [:name]
  defstruct name: nil,
            age: nil,
            happy?: true,
            phone: nil

  @type t() :: %__MODULE__{
          name: String.t(),
          age: non_neg_integer() | nil,
          happy?: boolean(),
          phone: String.t() | nil
        }
end

As you can see, the names of the keys and defaults are separate from the type information. This can get pretty hairy if you have a large, complex data structure and is very unintuitive if you aren’t already familiar with type specs. Luckily, I stumbled upon the TypedStruct package, which is a compile-time macro that dramatically improves the syntax. Using TypedStruct, the above example would look like this:

defmodule Person do
  use TypedStruct

  typedstruct do
    field :name, String.t(), enforce: true
    field :age, non_neg_integer()
    field :happy?, boolean(), default: true
    field :phone, String.t()
  end
end

Now we’re talking! This is immediately understandable to anyone coming across it, particularly if you’re used to the DSL used to define schemas in Ecto.

So then what happens when I use the struct improperly?

defmodule PersonUser do
  @spec init_person() :: Person.t()
  def init_person do
    %Person{name: nil, age: -1, happy?: "yes"}
  end
end

NeoVim, configured properly with Coc.nvim, gives me a popup warning message:

NeoVim Elixir Typespec Warning Message

And VSCode, with the Elixir-LS extension, will do the same:

VSCode Elixir Typespec Warning Message

In an ideal world, the program wouldn’t compile. But this is still a huge help, particularly in a large codebase that you’re not completely familiar with.