← Back to DevBytes

Elixir Type System: Static vs Dynamic Typing

Understanding Elixir's Type System

Elixir occupies a fascinating position in the programming language landscape. It is fundamentally a dynamically typed language, yet it provides powerful tools for static type analysis through its type specification system and the Dialyzer tool. This hybrid approach — often called gradual typing — gives developers the best of both worlds: the flexibility and expressiveness of dynamic typing during development, and the safety guarantees of static analysis before deployment.

The Foundation: Dynamic Typing in Elixir

In Elixir, variables carry values, not type declarations. You never annotate a variable with a type like int or string. Instead, the runtime determines types dynamically based on the values flowing through your program. This is a core characteristic inherited from Erlang and the BEAM virtual machine.

Consider this simple module that works with different types at runtime:

defmodule DynamicDemo do
  def process(value) do
    case value do
      x when is_integer(x) -> "Got an integer: #{x * 2}"
      x when is_binary(x) -> "Got a string: #{String.upcase(x)}"
      x when is_list(x)   -> "Got a list with #{length(x)} elements"
      _                   -> "Unknown type, doing my best!"
    end
  end
end

# All of these work perfectly at runtime
IO.puts DynamicDemo.process(42)           # => "Got an integer: 84"
IO.puts DynamicDemo.process("hello")      # => "Got a string: HELLO"
IO.puts DynamicDemo.process([1, 2, 3])    # => "Got a list with 3 elements"
IO.puts DynamicDemo.process(:atom_value)  # => "Unknown type, doing my best!"

This is dynamic typing in action. The function process/1 accepts any value, and the runtime dispatches based on pattern matching and guard clauses. There is no compile-time enforcement that restricts what types can be passed. If you pass an unexpected type, you won't know until the code executes — and in this example, the catch-all clause handles it gracefully. But in real-world code, unexpected types often cause runtime crashes.

Strong vs Weak Typing: An Important Distinction

Before diving deeper, let's clarify a common confusion. Elixir is dynamically typed but strongly typed. This means types are checked at runtime and implicit coercions are rare. You cannot accidentally add a number to a string:

# This raises an error at runtime — Elixir does NOT coerce types
value = 10 <> "hello"

** (ArgumentError) expected binary argument to <<binary>>/2, got 10

This strong typing guarantees that operations either succeed with compatible types or fail explicitly. You never get silent bugs from implicit type coercion, as you might in weakly typed languages. The runtime consistently checks operand types for every operation.

Why Type Safety Matters in Elixir

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Elixir is often used to build fault-tolerant, long-running systems — web servers, message brokers, financial platforms. In these domains, type-related bugs can cause subtle failures that propagate through supervision trees. A function expecting a map with a specific structure might receive a list, crash the process, and trigger a supervisor restart. While the system recovers, the root cause remains.

Here's a scenario that illustrates the risk:

defmodule OrderProcessor do
  def apply_discount(order, discount_percentage) do
    order.total * (1 - discount_percentage / 100)
  end
end

# Works fine
OrderProcessor.apply_discount(%{total: 100}, 20)

# Runtime crash — discount_percentage is a string, not a number
OrderProcessor.apply_discount(%{total: 100}, "20")

** (ArithmeticError) bad argument in arithmetic expression

At runtime, passing "20" instead of 20 crashes the arithmetic operation. In a large codebase with dozens of modules calling each other, tracking down which caller passed the wrong type can be time-consuming. This is where Elixir's static typing tools become invaluable.

Enter Type Specifications: @spec and @type

Elixir provides a type specification language that lets you annotate functions with expected input and output types. These annotations live in your source code as @spec attributes and custom @type definitions. They serve as machine-checkable documentation that Dialyzer can verify.

Basic @spec Annotations

A type spec is written directly above a function definition using the @spec directive. It declares the parameter types and the return type:

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

  @spec divide(integer(), integer()) :: {:ok, float()} | {:error, String.t()}
  def divide(_a, 0), do: {:error, "Division by zero"}
  def divide(a, b), do: {:ok, a / b}

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

Here, integer(), float(), and String.t() are built-in type aliases. The pipe character | creates a union type — the divide/2 function returns either an {:ok, float()} tuple or an {:error, String.t()} tuple. These specs do not change runtime behavior; they are purely for static analysis.

Defining Custom Types with @type

For complex data structures, you can define your own types. This is especially useful for structs, map shapes, and recurring patterns:

defmodule Order do
  defstruct [:id, :customer_name, :total, :status]

  @type t() :: %__MODULE__{
    id: integer(),
    customer_name: String.t(),
    total: float(),
    status: :pending | :confirmed | :shipped | :delivered
  }

  @type order_id() :: integer()

  @spec create_order(order_id(), String.t(), float()) :: t()
  def create_order(id, customer_name, total) do
    %__MODULE__{
      id: id,
      customer_name: customer_name,
      total: total,
      status: :pending
    }
  end

  @spec confirm(t()) :: t()
  def confirm(order) do
    %{order | status: :confirmed}
  end
end

The @type t() convention is widely used across the Elixir ecosystem. It defines the canonical struct type for a module. Other types like order_id() provide semantic aliases that make specs more readable. Union types with specific atoms (:pending | :confirmed | :shipped | :delivered) restrict the possible values to a known set, preventing typos and invalid states.

Advanced Type Specifications

Elixir's type language supports parameterized types, allowing you to specify generic structures:

defmodule CollectionUtils do
  @type element() :: any()
  @type collection(element) :: [element] | %{required(integer()) => element}

  @spec find(collection(term()), (term() -> boolean())) :: {:ok, term()} | :not_found
  def find(collection, predicate) do
    case collection do
      list when is_list(list) ->
        Enum.find(list, fn item -> predicate.(item) end)
        |> case do
          nil -> :not_found
          item -> {:ok, item}
        end
      map when is_map(map) ->
        map
        |> Map.values()
        |> Enum.find(fn item -> predicate.(item) end)
        |> case do
          nil -> :not_found
          item -> {:ok, item}
        end
    end
  end
end

Here, collection(element) is a parameterized type that accepts any type variable element. The spec says: "given a collection of some type T and a predicate function from T to boolean, return either {:ok, T} or :not_found." This level of precision helps Dialyzer catch mismatches deep in call chains.

Dialyzer: Static Analysis in Action

Dialyzer (DIscrepancy AnalYZer for ERlang programs) is the static analysis tool that reads your type specs and finds inconsistencies. It ships with Erlang and works seamlessly with Elixir projects through Mix tasks.

Setting Up Dialyzer in a Mix Project

To add Dialyzer to your project, include the :dialyxir dependency in your mix.exs:

defp deps do
  [
    {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
  ]
end

Then fetch dependencies and generate the PLT (Persistent Lookup Table — a cache of type information for your dependencies):

mix deps.get
mix dialyzer --plt

Run Dialyzer with:

mix dialyzer

What Dialyzer Catches

Dialyzer performs whole-project analysis and identifies several classes of problems:

Let's look at a concrete example. Suppose you have these two modules:

defmodule UserService do
  @spec get_user(integer()) :: {:ok, map()} | {:error, String.t()}
  def get_user(id) when id > 0 do
    {:ok, %{id: id, name: "User #{id}"}}
  end
  def get_user(_), do: {:error, "Invalid ID"}
end

defmodule UserController do
  @spec display_user(integer()) :: String.t()
  def display_user(id) do
    case UserService.get_user(id) do
      {:ok, user} -> "Welcome, #{user.name}!"
      {:error, _reason} -> "Could not find user"
      :not_found -> "User missing"  # This case never happens!
    end
  end
end

Dialyzer would flag the :not_found clause as unreachable because the spec for get_user/1 declares it only returns {:ok, map()} or {:error, String.t()}. This is invaluable — it prevents dead code and reveals mismatches between a function's actual behavior and how callers handle its return values.

Understanding Dialyzer's Limitations

Dialyzer is not a full static type checker like those in Haskell or Rust. It operates on the principle of "no false positives" — if Dialyzer reports an error, it's a real problem, but silence does not guarantee correctness. Dialyzer uses a technique called success typing: it infers types based on what a function actually does in its body, rather than enforcing a declared spec. The spec acts as a contract that Dialyzer checks against the inferred types.

This means:

Despite these limitations, Dialyzer catches a significant percentage of real-world type bugs, especially in large codebases with comprehensive specs.

Pattern Matching and Guards: Runtime Type Enforcement

While Dialyzer provides compile-time analysis, Elixir developers rely heavily on pattern matching and guard clauses for runtime type enforcement. This is the practical, everyday mechanism for ensuring type correctness in production.

Guard Clauses as Type Checks

Guards allow you to assert types before entering a function body:

defmodule SafeParser do
  @spec parse_integer(term()) :: {:ok, integer()} | :error
  def parse_integer(value) when is_integer(value) do
    {:ok, value}
  end
  def parse_integer(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, ""} -> {:ok, int}
      _ -> :error
    end
  end
  def parse_integer(_), do: :error
end

# All safe, all explicit
SafeParser.parse_integer(42)       # => {:ok, 42}
SafeParser.parse_integer("42")     # => {:ok, 42}
SafeParser.parse_integer("hello")  # => :error
SafeParser.parse_integer([1, 2])   # => :error

The guard clauses when is_integer(value) and when is_binary(value) dispatch to the correct implementation. The catch-all clause handles everything else explicitly. This pattern — multiple clauses with type guards plus a fallback — is idiomatic Elixir and provides robust runtime type safety.

Pattern Matching on Structs

Structs provide another layer of type safety. By pattern matching on a specific struct, you ensure you're dealing with the expected data shape:

defmodule Invoice do
  defstruct [:number, :amount, :currency]

  @spec format(Invoice.t()) :: String.t()
  def format(%Invoice{number: num, amount: amt, currency: curr}) do
    "Invoice ##{num}: #{amt} #{curr}"
  end

  # This clause ONLY matches Invoice structs — nothing else
  def format(%Invoice{} = invoice) do
    "Invoice ##{invoice.number}: #{invoice.amount} #{invoice.currency}"
  end
end

# Works
Invoice.format(%Invoice{number: 101, amount: 250.0, currency: "USD"})

# Runtime error — no clause matches a plain map
Invoice.format(%{number: 101, amount: 250.0, currency: "USD"})

** (FunctionClauseError) no function clause matching in Invoice.format/1

The %Invoice{} pattern only matches instances of the Invoice struct, not arbitrary maps with the same keys. This is structural type enforcement at the pattern-matching level.

Best Practices for Elixir Type Safety

1. Write @spec for Every Public Function

Treat type specs as essential documentation. Every function in your public API should have an @spec. This creates a contract that both humans and Dialyzer can reference:

# Bad — no spec, caller must guess types
def transfer(from_account, to_account, amount) do
  # ...
end

# Good — spec clearly communicates expectations
@spec transfer(Account.t(), Account.t(), Money.t()) :: {:ok, Transaction.t()} | {:error, reason()}
def transfer(from_account, to_account, amount) do
  # ...
end

2. Define @type t() for Every Struct

The @type t() convention is a community standard. It makes your structs composable in specs across modules:

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

  @type t() :: %__MODULE__{
    id: integer() | nil,
    email: String.t(),
    name: String.t()
  }
end

Other modules can then reference User.t() in their own specs, creating a web of type documentation across your entire codebase.

3. Use Dialyzer in CI/CD Pipeline

Run Dialyzer as part of your continuous integration. A typical setup adds a script or CI step:

# In your CI config (e.g., GitHub Actions)
- run: mix dialyzer --plt
- run: mix dialyzer

This ensures every pull request is checked for type inconsistencies before merging. The initial PLT build takes time, but subsequent runs are fast because Dialyzer caches type information.

4. Leverage Pattern Matching for Runtime Guarantees

Even with Dialyzer, runtime type errors can occur — especially from external inputs (user data, API responses, message queues). Use pattern matching and guards at your system boundaries:

defmodule ApiGateway do
  @spec handle_webhook(map()) :: {:ok, term()} | {:error, :invalid_payload}
  def handle_webhook(%{"event" => "payment", "data" => data} = payload) do
    with {:ok, amount} <- extract_amount(data),
         {:ok, currency} <- extract_currency(data) do
      process_payment(amount, currency)
    end
  end
  def handle_webhook(_), do: {:error, :invalid_payload}

  defp extract_amount(%{"amount" => amt}) when is_number(amt), do: {:ok, amt}
  defp extract_amount(_), do: :error
end

5. Avoid Overly Generic Types

While Elixir allows any() and term() in specs, prefer concrete types. A spec like @spec process(any()) :: any() provides no information. Instead:

# Too generic — no useful information
@spec handle_event(any()) :: any()

# Better — constrains the domain
@spec handle_event(:user_signup | :order_placed | :payment_received) :: {:ok, State.t()} | {:error, term()}

6. Run Gradual Adoption

You don't need to add specs to your entire codebase at once. Start with the most critical modules — those handling money, authentication, or data persistence. Add specs incrementally. Dialyzer works fine with partial coverage; it analyzes what it can and remains silent about the rest.

Static Typing vs Dynamic Typing: The Trade-offs in Practice

Understanding when each approach shines helps you make better architectural decisions:

The combination gives you a development experience where you move fast during exploration and lock down types as you approach production — a pragmatic middle ground that many teams find highly productive.

Conclusion

Elixir's type system offers a pragmatic blend of dynamic flexibility and static safety. The language itself is dynamically typed, giving you the speed and expressiveness needed for rapid development. Its pattern matching and guard clauses provide robust runtime type enforcement that catches errors at the point of failure rather than silently propagating bad data. And through @spec, @type, and Dialyzer, Elixir delivers opt-in static analysis that scales with your project's complexity. By adopting these tools incrementally — writing specs for public functions, defining struct types, running Dialyzer in CI, and enforcing type boundaries with pattern matching — you build systems that are both flexible to change and resilient to type-related bugs. This gradual typing approach is not a compromise; it's a deliberate design that respects how software actually evolves, from prototype to production.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles