Understanding Erlang's Type System: The Dynamic Core with Static Ambitions
Erlang occupies a fascinating middle ground in the programming language landscape. At runtime, it is dynamically typed through and through. Yet its ecosystem provides one of the most sophisticated static analysis tools in the business: Dialyzer. This creates a unique hybrid approach that gives developers the best of both worldsârapid prototyping and fearless refactoring without sacrificing the safety net of type checking.
What Is Erlang's Type System, Really?
Erlang is dynamically typed at its core. Variables carry values, not type declarations. A variable bound to an integer can later be bound to a list in a different clause. The runtime system checks types on the fly and throws exceptions when operations fail. There is no compiler-enforced type system that prevents you from running your code.
However, Erlang provides an optional type specification language that feeds into Dialyzer, a static analysis tool that examines your code without running it. Dialyzer does not reject programs that fail type checks. Instead, it reports discrepancies it discovers, and you decide what to do about them. This is fundamentally different from languages like Haskell, TypeScript, or Rust where the type checker gates compilation.
The type specification syntax allows you to annotate functions with -spec attributes and define custom types with -type declarations. These annotations serve three purposes: documentation for other developers, input for Dialyzer's analysis, and guidance for editors and language servers that provide inline feedback.
%% Type definitions live in module attributes
-type user_id() :: integer().
-type user_record() :: #{id => user_id(), name => binary(), age => non_neg_integer()}.
%% Function specification example
-spec lookup_user(user_id()) -> {ok, user_record()} | {error, not_found}.
lookup_user(Id) ->
case db:find(Id) of
{ok, Record} -> {ok, Record};
undefined -> {error, not_found}
end.
Dynamic Typing in Practice: The Runtime Reality
When you fire up the Erlang VM, types evaporate. The BEAM cares about values, not type declarations. Here is a module that leans entirely on dynamic typing:
-module(dynamic_demo).
-export([process/1, safe_divide/2]).
%% This function accepts literally anything
process(Data) ->
case Data of
X when is_integer(X) ->
io:format("Integer: ~p~n", [X * 2]);
X when is_list(X) ->
io:format("List length: ~p~n", [length(X)]);
X when is_binary(X) ->
io:format("Binary size: ~p~n", [byte_size(X)]);
_ ->
io:format("Unknown type, carrying on~n", [])
end.
%% Pattern matching catches type mismatches at runtime
safe_divide(A, B) when is_number(A), is_number(B), B =/= 0 ->
A / B;
safe_divide(_, _) ->
{error, invalid_arguments}.
This works perfectly. Erlang's pattern matching and guard expressions give you runtime type checking that feels natural. The is_integer/1, is_list/1, and similar guard functions let you branch on type at runtime. If you call safe_divide("hello", 5), you get {error, invalid_arguments} instead of a crash.
The dynamic approach shines in several areas: hot code reloading (types can change between versions of a running system), message passing between processes (you send what you want, and the receiver decides how to handle it), and rapid prototyping (no ceremony, just write code and test it).
The Static Side: Dialyzer and Type Specifications
Dialyzer (DIscrepancy AnaLYZER for ERlang programs) works differently from a traditional type checker. It does not attempt to prove your program is correct. Instead, it looks for contradictions. If you declare a function returns integer() but Dialyzer finds a code path that returns a list, it reports a discrepancy. If your code never violates your spec in any path Dialyzer can see, it remains silent.
Here is a module with rich type specifications that Dialyzer can analyze:
-module(user_manager).
-export([create_user/1, get_user_age/1, update_user/2]).
-export_type([user/0, username/0]).
%% Custom type definitions
-type username() :: binary().
-type user() :: #{
name := username(),
age := non_neg_integer(),
email := binary(),
tags := [binary()]
}.
%% Function specifications
-spec create_user(map()) -> {ok, user()} | {error, invalid_input}.
create_user(Input) ->
case validate_user_input(Input) of
ok ->
User = build_user_record(Input),
{ok, User};
{error, Reason} ->
{error, Reason}
end.
-spec get_user_age(user()) -> non_neg_integer().
get_user_age(#{age := Age}) ->
Age.
-spec update_user(user(), map()) -> {ok, user()} | {error, unchanged}.
update_user(User, Updates) ->
UpdatedUser = maps:merge(User, Updates),
case UpdatedUser =:= User of
true -> {error, unchanged};
false -> {ok, UpdatedUser}
end.
%% Private functions with specs for clarity
-spec validate_user_input(map()) -> ok | {error, binary()}.
validate_user_input(#{name := Name, age := Age, email := Email})
when is_binary(Name), is_integer(Age), Age >= 0, is_binary(Email) ->
ok;
validate_user_input(_) ->
{error, <<"Missing or invalid fields">>}.
-spec build_user_record(map()) -> user().
build_user_record(#{name := Name, age := Age, email := Email}) ->
#{name => Name, age => Age, email => Email, tags => []}.
Run Dialyzer on this module, and it will verify that get_user_age/1 always returns a non-negative integer when given a user() map, and that create_user/1 never leaks an improperly typed record. If you accidentally return a raw map instead of a properly tagged user record, Dialyzer catches the discrepancy before it reaches production.
Why This Hybrid Approach Matters
The Erlang type system strategy is not an accident. It reflects the realities of building fault-tolerant, long-running distributed systems:
- Hot code reloading demands runtime flexibility. When you upgrade a module in a running system, the new code must handle messages from older code that may have different shapes. Static guarantees at compile time cannot anticipate every runtime version combination.
- Message-passing architectures benefit from loose coupling. Processes communicate via asynchronous messages. The receiver decides how to interpret data. This is inherently dynamic and a core strength of the actor model.
- Supervision trees recover from type errors gracefully. In Erlang, a type error is not a program crashâit is a process crash. The supervisor restarts the process, the system keeps running. Static typing that prevents compilation would hinder this resilience model.
- Gradual adoption fits real-world codebases. You can add type specs to a 500,000-line codebase one module at a time. Dialyzer never blocks you from running code. This is in stark contrast to migrating a JavaScript codebase to TypeScript with strict mode enabled.
How to Use Type Specifications Effectively
Adding type specifications to your Erlang code is straightforward. Start with your exported functionsâthese are your module's public contract. Work inward to private helpers as time allows. Here is a practical workflow:
-module(order_processor).
-export([process_order/1, calculate_total/2]).
-export_type([order/0, item/0, currency/0]).
%% 1. Define your domain types first
-type item_id() :: integer().
-type quantity() :: pos_integer().
-type price() :: {non_neg_integer(), non_neg_integer()}. %% {dollars, cents}
-type item() :: #{id => item_id(), name => binary(), price => price()}.
-type order() :: #{items => [item()], discount => float()}.
-type currency() :: usd | eur | gbp.
%% 2. Spec your exported functions
-spec process_order(order()) -> {ok, float()} | {error, empty_order}.
process_order(#{items := []}) ->
{error, empty_order};
process_order(#{items := Items, discount := Discount}) ->
Total = calculate_subtotal(Items),
DiscountedTotal = Total * (1.0 - Discount),
{ok, DiscountedTotal}.
-spec calculate_total([item()], currency()) -> {currency(), float()}.
calculate_total(Items, Currency) ->
Subtotal = calculate_subtotal(Items),
{Currency, Subtotal}.
%% 3. Spec private functions for Dialyzer coverage
-spec calculate_subtotal([item()]) -> float().
calculate_subtotal(Items) ->
lists:foldl(fun(Item, Acc) ->
Acc + item_price_to_float(maps:get(price, Item))
end, 0.0, Items).
-spec item_price_to_float(price()) -> float().
item_price_to_float({Dollars, Cents}) ->
float(Dollars) + float(Cents) / 100.0.
When you add a -spec, you are making a promise. Dialyzer holds you to it. If calculate_subtotal/1 somehow returns an integer, Dialyzer will flag the mismatch with process_order/1 which expects a float. This is invaluable before refactoringâchange the price representation from a tuple to a float, run Dialyzer, and it tells you every function that needs updating.
Advanced Type Specifications: Polymorphism and Overloaded Specs
Erlang's type language supports type variables for polymorphic functions. This lets you express that a function preserves types across its arguments and return value without committing to a concrete type:
-module(list_utils).
-export([find/2, partition/2, zip/2]).
%% Type variable A means "any type, but consistent"
-spec find(fun((A) -> boolean()), [A]) -> {ok, A} | error.
find(Pred, [H | T]) ->
case Pred(H) of
true -> {ok, H};
false -> find(Pred, T)
end;
find(_, []) ->
error.
%% Multiple type variables express relationships
-spec partition(fun((A) -> boolean()), [A]) -> {[A], [A]}.
partition(Pred, List) ->
lists:foldr(fun(Elem, {Yes, No}) ->
case Pred(Elem) of
true -> {[Elem | Yes], No};
false -> {Yes, [Elem | No]}
end
end, {[], []}, List).
%% Type variables A and B can be different
-spec zip([A], [B]) -> [{A, B}].
zip([H1 | T1], [H2 | T2]) ->
[{H1, H2} | zip(T1, T2)];
zip(_, _) ->
[].
You can also provide multiple specifications for the same function using overloaded specs, separated by semicolons:
-module(string_handler).
-export([convert/1]).
%% Overloaded specs: the function accepts multiple concrete types
-spec convert(binary()) -> binary();
(integer()) -> binary();
(float()) -> binary().
convert(Data) when is_binary(Data) ->
Data;
convert(Data) when is_integer(Data) ->
integer_to_binary(Data);
convert(Data) when is_float(Data) ->
float_to_binary(Data, [{decimals, 2}]).
Dialyzer uses overloaded specs to narrow the expected types for each branch, providing more precise checking than a single broad spec like (any()) -> binary() would.
Best Practices for Type-Driven Development in Erlang
Over years of Erlang development, a set of practices has emerged that maximizes the value of the type system while respecting Erlang's dynamic nature:
- Type all exported functions without exception. Public interfaces are contracts. Even if the types seem trivial, a
-speccommunicates intent to callers and to Dialyzer. A missing spec on an exported function is a red flag in code review. - Define opaque types with
-opaquefor abstract data types. When a module owns a data structure and callers should not inspect its internals, use-opaqueinstead of a plain-type. Dialyzer enforces that callers treat the type as a black box, preventing accidental coupling to internal representation. - Run Dialyzer as part of your CI pipeline. Treat Dialyzer warnings like compiler warningsâaddress them before merging. A clean Dialyzer run on every commit prevents type regressions from accumulating.
- Use
-specbefore function bodies when writing new code. Writing the spec first clarifies your thinking. It forces you to decide what the function accepts and returns before you get lost in implementation details. - Leverage gradual adoption. You do not need 100% spec coverage. Start with the modules that have the most complex data transformations or the most callers. Every spec you add improves Dialyzer's ability to trace type errors across module boundaries.
- Keep types in dedicated header files for shared domain types. When multiple modules use the same record or map structure, define the type in an
.hrlfile and include it. This prevents type definition drift across modules.
Here is an example of opaque types and header file organization:
%% File: domain_types.hrl
%% Shared domain types included across modules
-type customer_id() :: integer().
-type account_id() :: integer().
-type transaction_id() :: binary().
%% Opaque type: callers cannot pattern-match the internals
-opaque encrypted_key() :: binary().
%% File: crypto_service.erl
-module(crypto_service).
-export([generate_key/0, encrypt_data/2]).
-include("domain_types.hrl").
%% The opaque type is created and consumed only by this module
-spec generate_key() -> encrypted_key().
generate_key() ->
%% Internal representation: a binary
Key = crypto:strong_rand_bytes(32),
%% Tag it as opaque for external callers
Key.
-spec encrypt_data(binary(), encrypted_key()) -> binary().
encrypt_data(Plaintext, Key) ->
crypto:encrypt(Plaintext, Key).
%% Only this module can deconstruct the opaque type
-spec internal_key_bytes(encrypted_key()) -> binary().
internal_key_bytes(Key) ->
Key.
Common Pitfalls and How to Avoid Them
Even experienced Erlang developers stumble on certain aspects of the type system. Here are the most frequent issues:
-module(pitfall_demo).
-export([bad_spec/1, any_vs_term/1, map_key_optionality/1]).
%% PITFALL 1: Overly broad specs defeat Dialyzer's purpose.
%% This spec says "anything goes" which tells Dialyzer nothing.
-spec bad_spec(any()) -> any().
bad_spec(X) -> X.
%% BETTER: Be specific. Even a union type is more informative.
-spec better_spec(integer() | binary() | float()) -> binary().
better_spec(X) when is_integer(X) -> integer_to_binary(X);
better_spec(X) when is_float(X) -> float_to_binary(X);
better_spec(X) when is_binary(X) -> X.
%% PITFALL 2: Confusing any() and term().
%% any() means Dialyzer should not warn about this value.
%% term() means literally any Erlang term, but Dialyzer still checks usage.
%% Prefer term() for polymorphic code; use any() sparingly.
%% PITFALL 3: Map type specs that are too loose.
%% This spec says "a map with at least name and age keys, but maybe more"
-spec map_key_optionality(#{name := binary(), age := integer()}) -> binary().
map_key_optionality(#{name := N, age := A}) ->
<>.
%% If you require EXACT keys, use a closed map spec:
-spec strict_map(#{name => binary(), age => integer()}) -> binary().
strict_map(#{name := N, age := A}) ->
<>.
%% PITFALL 4: Ignoring Dialyzer warnings on production code.
%% Every warning is a potential runtime crash. Fix them.
%% Suppress only with explicit understanding using:
%% -dialyzer({nowarn_function, [function_name/arity]}).
Another subtle pitfall involves recursive types. When defining types that refer to themselves, you must ensure the recursion is well-founded:
-module(tree_example).
-export_type([tree/1]).
%% Correct recursive type definition
-type tree(A) :: leaf
| {node, A, tree(A), tree(A)}.
%% Function over the recursive type
-spec sum_tree(tree(integer())) -> integer().
sum_tree(leaf) ->
0;
sum_tree({node, Value, Left, Right}) ->
Value + sum_tree(Left) + sum_tree(Right).
%% PITFALL: Forgetting the base case in a recursive type
%% This would cause Dialyzer confusion:
%% -type infinite_list(A) :: {cons, A, infinite_list(A)}.
%% Missing the nil/empty base case makes it uninhabited
Integrating Dialyzer into Your Workflow
Running Dialyzer is straightforward. First, build a Persistent Lookup Table (PLT) that contains the type information for all your dependencies including OTP:
# Build the PLT once (takes a few minutes, caches results)
dialyzer --build_plt --apps erts kernel stdlib crypto mnesia sasl
# Add your project dependencies to the PLT
dialyzer --add_to_plt --apps my_deps/*/ebin
# Analyze your project
dialyzer --plt ~/.dialyzer_plt --src src/**/*.erl --include include/
# For rebar3 projects, it is even simpler:
rebar3 dialyzer
The PLT is a cache. Once built, incremental analysis runs in seconds. Commit the PLT configuration to your repository so every developer uses the same baseline.
Conclusion
Erlang's type system is a deliberate, pragmatic compromise. It rejects the rigidity of mandatory static typing that would clash with hot code reloading and the actor model's loosely coupled message passing. At the same time, it embraces the discipline of type specifications and static analysis through Dialyzer, giving you a powerful tool that catches real bugs without ever blocking your ability to run code.
The path to mastery involves embracing both sides: write code that leverages Erlang's dynamic strengths for message handling and fault recovery, while systematically annotating your modules with type specifications that let Dialyzer guard your logic. Start with your public interfaces, define domain types in shared headers, treat Dialyzer warnings seriously, and enjoy the confidence that comes from knowing your 200,000-line distributed system has been scrutinized by one of the most battle-tested static analyzers in the industryâall without sacrificing the dynamism that makes Erlang uniquely suited for building systems that never stop.