Introduction

If you’re like me, you love Elixir because it feels like writing poetry with a REPL. No compile-time type system slowing you down, no ceremony just to define a function. It’s all about expressiveness, readability, and getting stuff done.

But sometimes, especially as your codebase grows, you start wondering: “Would a little more structure help here?”

Welcome to the world of optional typing in Elixir — where types exist, but they don’t get in your way. Let’s explore what Elixir offers, why it works the way it does, and when you might (or might not) want to use it.


Elixir Is Dynamically Typed, But Not Untyped

Let’s get one thing straight: Elixir has types. You use them all the time:

"hello"         # a binary (string)
:ok             # an atom
123             # an integer
%{key: "value"} # a map
[1, 2, 3]       # a list
%User{name: "nico"} # a struct

But Elixir doesn’t check these types at compile-time. Instead, it leans on:

  • Pattern matching
  • Clear function contracts (visually, not enforced)
  • Structs to shape data
  • Runtime errors when types don’t line up

This gives you a lot of flexibility and fast feedback loops. You’re not fighting a type checker to make progress.

And honestly? Most of the time, that’s enough.


When Elixir Helps You Avoid Types Altogether

1. Pattern Matching is Your Best Friend

You can express intent and enforce expectations without writing a type:

def greet(%User{name: name}) do
  "Hello, #{name}!"
end

greet(%User{name: "Nico"})

If the function receives anything other than a %User{}, it’ll raise. So you already get type safety — at runtime — without extra syntax.

2. Guards Let You Be Explicit

Need a bit more control?

def double(x) when is_integer(x), do: x * 2
def double(_), do: raise ArgumentError

No @spec, no Dialyzer — just plain Elixir doing the job.

3. Structs Give Shape to Your Data

Structs are like lightweight types. They’re enforced at runtime, and they make your data self-documenting:

defmodule User do
  defstruct [:name, :email]
end

You know exactly what a %User{} looks like — and misuse is obvious.


Okay, But What If You Do Want Types?

Sometimes you’re writing a public API. Or handing code to teammates. Or returning deeply nested data that needs documentation.

That’s when typespecs and Dialyzer come in.


Meet Dialyzer: The Typing Cop That Doesn’t Yell

Dialyzer is Elixir’s static analysis tool. You pair it with optional @spec annotations to check for type issues.

Example:

@spec square(integer()) :: integer()
def square(x), do: x * x

Dialyzer checks if square/1 is used correctly across your project. But it doesn’t force you to write specs. It doesn’t block compilation. And sometimes… it doesn’t catch bugs either.

It’s helpful — not annoying.


Typespecs: Optional, But Often Useful

You can annotate any function with a typespec:

@spec greet(String.t()) :: String.t()
def greet(name), do: "Hello, #{name}!"

Elixir gives you built-in types:

  • integer(), float(), boolean()
  • list(type), map(), tuple()
  • {:ok, value} | {:error, reason}

And you can define your own:

@type user_id :: integer()
@spec get_user(user_id()) :: %User{} | nil

Typespecs are great when:

  • You’re writing libraries or public modules
  • You have complex return types
  • You want editors (like LSPs) to help you out

But skip them when:

  • Code is short and obvious
  • You’re prototyping or iterating fast
  • The extra syntax hurts readability

Real-World Examples

Example 1: A Simple Typespec

@spec add(integer(), integer()) :: integer()
def add(a, b), do: a + b

Helpful? Meh. Maybe for documentation.

Example 2: A Return Type That Deserves a Spec

@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id) do
  case Repo.get(User, id) do
    nil -> {:error, :not_found}
    user -> {:ok, user}
  end
end

This is clearer with the spec — especially when other devs use it.

Example 3: Defining Custom Types

@type status :: :active | :inactive | :banned

@spec update_status(User.t(), status()) :: User.t()
def update_status(user, new_status), do: %{user | status: new_status}

Now you’ve documented all possible values for status. Nice!


The Downsides of Over-Typing

Look, you can write types for everything. But should you?

Too many specs:

  • Add noise to simple code
  • Don’t guarantee correctness (Dialyzer isn’t perfect)
  • Can get out of sync if you’re not careful

The goal isn’t to prove your program is correct — it’s to help humans understand it.

Elixir’s philosophy is: trust the developer. Add structure where it helps, skip it where it doesn’t.

And that’s… refreshing.


Tips for Typing in Elixir Without Losing Your Mind

  1. Use structs to shape data, not just maps.
  2. Use @spec on public functions — especially in shared code.
  3. Skip @spec on private, obvious helpers. It’s fine.
  4. Use types to document return values, especially tagged tuples.
  5. Don’t rely on Dialyzer for correctness. It’s a helpful tool, not a guardian angel.
  6. Let types guide design, but not dominate it.

My Take as a Dev Who Doesn’t Like Typing

Honestly? I love how Elixir lets me opt-in.

When I want to move fast and explore ideas — no types needed. When I want to lock things down or write better docs — boom, typespecs.

It’s the best of both worlds.

I don’t need a compiler barking at me. I need clarity, simplicity, and room to think. And Elixir delivers exactly that.


Wrapping Up

Typing in Elixir is flexible, expressive, and human-first. You can go a long way without @spec or Dialyzer. But when used wisely, typespecs can make your codebase easier to navigate, safer to change, and nicer to document.

It’s not about being strictly typed. It’s about being intentionally clear.

And in Elixir, you get to choose how much help you want.

Freedom, with guardrails — just how I like it.


Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht