Leveling up your Elixir codebase with types

WTTJ Tech
Welcome Tech
Published in
7 min readMay 23, 2023

As you probably already know, Elixir is a dynamic and functional language. This allows for quick iterations on what your data look like, and even easy composability by letting you enhance a variable. But all this comes at a cost.

With languages like Elixir, the only way of knowing what to pass to a function or what it will return is by looking at the code, or handmade documentation. But what if you are new to the project? Or even worse, what if the project you are working on hasn’t been touched for several years? We have all had that kind of experience of “legacy” projects, as we call them. In such cases, you may think that all you need to do is look at the tests to be able to understand a bit better how everything is structured or what is expected to happen. But then you see that the specific code you need to work on hasn’t been well tested, or maybe even that it hasn’t been tested at all.

This is a classic scenario where type specifications would be a huge help, although types can help in many other ways too.

The importance of types

Types are a fundamental aspect of any programming language. They define the structure of data and the operations that can be performed on them. Here’s why types are essential in Elixir…

  • Improved legibility: Types make your code more explicit, providing better context for other developers. By specifying the expected data types, you help others understand your code and make it easier to work with. They will know what to provide for your functions, and even what is expected for a given use case or context. Thanks to recent LSP updates, types can be used to provide auto-completion on your structs, modules, and functions.
  • Easier debugging: Type information can help identify potential bugs earlier in the development process. Type-checking tools (aka linters) can catch type errors before runtime, reducing the risk of encountering unexpected issues. In Elixir land, this is done by Dialyzer.
  • Self-documenting code: By specifying types, you are effectively documenting the input and output expectations of your functions. This reduces the need for external documentation, making your code more maintainable over time. ExDoc can help with this and is the primary documentation tool used for Elixir libraries.

How to use types in Elixir

Types in Elixir land

There are built-in mechanisms and keywords in Elixir that help with type definition. All of this is well documented. But you first need to know that a type specification can be used as a function that returns a type structure.

In the standard library, you already have access to predefined types that represent basic structures, like boolean, integer, string, and much more. But they can also contain some specifications like non_neg_integer(), which represents an unsigned integer.

Defining a type

To demonstrate what we have just talked about, let’s look at our famous todo list example and define a struct that represent a todo:

As you can see, we use the keyword @type to define a type in a module. Since we want to describe the structure that is directly at the root of the module, we simply use %__MODULE__{} to specify that the type is coupled with the name of the current module.

As the above example shows, we have access to basic types, such as non_neg_integer() or boolean(), as mentioned earlier.

But we can also see String.t(), which is a direct call to a compile time function called fon the String module (remember what I said about types being used with function calls?). So here we are asking the String module to give us the type structure of, well, a string. But why a function called t, and how did we know to use this one? Well, tis, by convention, the function for the type of a root struct in a module.

You can also see that we wrote @type tin our example, which means that we follow the same convention. With this, we have made our first type accessible by calling Todo.t(). You can also define other types that are used in the module, like @type Address.

Using types with specs

So, now that we have a type, how can we use it? Let’s adapt our previous example to add a typed function, which will return the content of a todo:

We are using the @spec keyword here to add a specification for our function. We then repeat the name of the function and we add types as parameters. You can see that since we previously added the type specification of a todo as the function t, we can use it here as the first and only parameter. This will automatically call the function in the current module, but if we were in another module, we could use it with Todo.t(). Then we define the return type by putting :: and the expected type, a string in our case.

So now you are finally able to see the real advantage of types here, since if we try to return todo.created_at instead of todo.content, the linter will automatically throw up an error, since created_at is not defined on our Todo type (assuming you have Dialyzer set up as a linter in your editor).

Now we know that, even before we run anything, this code will crash and we also have a helpful message about what is wrong.

That’s not only nice for us, but if other developers have set up LSP in their editors, they will also get nice formatted errors and warnings, as well as auto-completion, when using our module. Please note that everything laid out here can also be applied to libraries like Ecto since they output Elixir structs under the hood.

The drawbacks of type definition

We are only scratching the surface here, since we can now use our newly created type definitions to generate documentation, for example, as well as in many other use cases. But we might notice a drawback or two when adopting this approach.

Since Elixir is not strongly typed, all those bells and whistles only apply if third-party tools like linters and LSP are also used, and if we make a mistake with a type definition, our code base will compile as if there were no additional features.

Another drawback is that, in our example, we need to write the content twice — once for the type definition, and then again for the struct itself. This will lead to an overhead when we need to update our structs or Ecto schemas. But we can do it better with third-party tools. And this particular case also applies to other concepts, such as enforced_keys.

Third-party helpers

To try to resolve the drawbacks we have described above, many libraries can help with type definitions, but I would like to focus here on two that help with developer experience: TypedStruct and TypedEctoSchema.

To demonstrate how those libraries can be useful, let’s take our previous example and convert it to use TypedStruct.

What you see here is the same definition of type and struct at the same time from our todo example. Magic, you say? Well, not really. TypedStruct is a set of macros that will generate the correct typespec and struct definition at compile time. Those macros can take parameters such as the field name, the type, and the default value, but also others, to set a key as enforced, for example. But let’s leave enforced_keys for another time.

The huge advantage here is that you define the struct and type at the same time, which prevents you from making any errors while duplicating the definition or updating the struct. You can then use your struct as if you were not using a library under the hood, since once it’s compiled, you get the struct and type definition.

The same goes for your Ecto Schemas with TypedEctoSchema, which works the same way as TypedStruct but has more parameters to handle Ecto options.

These two helpers can do much more, but I will leave it to you to read through their documentation to get an idea of what advantages they can bring to your code base.

Thanks for reading!

As we have seen, types in Elixir, while not mandatory, can greatly improve your code base and developer experience while simultaneously preventing the project from falling into the “legacy” space. In addition, TypedStruct and TypedEctoSchema can save you time and effort while writing type specifications.

José Valim also did a really great talk about why and how he wants to add strong typing to the language. There is ongoing research about doing this the right way and we will soon know more about it.

But other approaches can help with the problems that types can resolve. Ash Framework, for example, generates code based on previously defined models, which can help you to write less code and thus fewer types.

Written by Sylvain Colignon, Fullstack developer @ WTTJ

Edited by Anne-Laure Civeyrac

Illustration by Clara Dupré

Join our team!

--

--