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 have 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

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()

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"}

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.