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
- Use structs to shape data, not just maps.
- Use
@spec
on public functions — especially in shared code. - Skip
@spec
on private, obvious helpers. It’s fine. - Use types to document return values, especially tagged tuples.
- Don’t rely on Dialyzer for correctness. It’s a helpful tool, not a guardian angel.
- 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.
Share on
Twitter Facebook LinkedInHave comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht