We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
Type-Safe APIs: Building Robust Elixir Type Systems
Compile-time safety through @type, @spec, and Dialyzer
Prismatic Engineering
Prismatic Platform
Dynamic Typing Is Not Enough
Elixir is dynamically typed, which gives it flexibility and rapid development
speed. But for a platform with 94 apps and thousands of inter-module function
calls, dynamic typing creates a problem: errors surface at runtime instead of
compile time. A misspelled map key or a function returning an unexpected type
can propagate silently until it crashes in production.
The Prismatic Platform addresses this through a comprehensive type system
built on @type, @spec, and Dialyzer analysis.
The Type Architecture
The platform defines domain-specific type modules that centralize type
definitions:
defmodule PrismaticWeb.Types.ApiResponseTypes do
@moduledoc "Comprehensive type definitions for API responses."
@type api_response :: %{
status: :ok | :error,
data: term(),
meta: response_meta(),
version: String.t()
}
@type response_meta :: %{
request_id: String.t(),
timestamp: DateTime.t(),
duration_ms: non_neg_integer()
}
@type paginated_response :: %{
status: :ok,
data: [term()],
meta: response_meta(),
pagination: pagination()
}
@type pagination :: %{
page: pos_integer(),
per_page: pos_integer(),
total_pages: pos_integer(),
total_count: non_neg_integer()
}
end
By centralizing types, all modules that produce or consume API responses
reference the same definitions. Changes to the type structure are caught
by Dialyzer across the entire codebase.
@spec for Every Public Function
The DOCS doctrine requires @spec on every public function. Specs serve
two purposes: documentation for developers and contracts for Dialyzer:
@spec create_entity(map()) :: {:ok, Entity.t()} | {:error, Ecto.Changeset.t()}
def create_entity(attrs) do
%Entity{}
|> Entity.changeset(attrs)
|> Repo.insert()
end
@spec list_entities(keyword()) :: [Entity.t()]
def list_entities(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
Entity
|> limit(^limit)
|> Repo.all()
end
When a caller passes incorrect types or ignores an error tuple, Dialyzer
flags the issue at compile time.
Type Constructors and Validation
Raw maps are error-prone because any key can be added or omitted. The
platform uses constructor functions that validate structure:
defmodule PrismaticDD.Types.Finding do
@type t :: %__MODULE__{
id: String.t(),
severity: :critical | :high | :medium | :low,
description: String.t(),
evidence: [String.t()],
confidence: float()
}
@enforce_keys [:id, :severity, :description]
defstruct [:id, :severity, :description, evidence: [], confidence: 0.0]
@spec new(map()) :: {:ok, t()} | {:error, String.t()}
def new(attrs) when is_map(attrs) do
with {:ok, id} <- validate_id(attrs),
{:ok, severity} <- validate_severity(attrs),
{:ok, description} <- validate_description(attrs) do
{:ok, %__MODULE__{
id: id,
severity: severity,
description: description,
evidence: Map.get(attrs, :evidence, []),
confidence: Map.get(attrs, :confidence, 0.0)
}}
end
end
end
The @enforce_keys attribute ensures that struct creation without required
fields raises a compile-time error. The new/1 constructor adds runtime
validation for data coming from external sources.
Dialyzer Integration
Dialyzer performs success typing analysis: it infers types from code
and checks them against @spec annotations. The platform runs Dialyzer
in CI with zero tolerance for warnings:
mix dialyzer --format github
Common Dialyzer catches in the platform:
{:ok, t()} but have code paths returningnil
Avoiding Common Pitfalls
The `any()` Escape Hatch
Using @spec foo(any()) :: any() defeats the purpose of type checking.
The platform's DOCS doctrine treats any() in specs as a code smell that
requires justification.
Opaque Types
For types that should not be pattern-matched externally, use @opaque:
@opaque t :: %__MODULE__{internal: map()}
This tells Dialyzer to flag any code outside the module that inspects the
struct's internal structure.
Union Types for Error Handling
Explicit error types make error handling exhaustive:
@type create_error ::
:duplicate_slug
| :invalid_category
| {:validation_failed, [String.t()]}
@spec create(map()) :: {:ok, t()} | {:error, create_error()}
Callers can then pattern match on specific error variants rather than
catching a generic {:error, term()}.
The Type Safety Pyramid
The platform's type safety operates at three levels:
|-------|-----------|-------------|
mix compile / mix dialyzerInternal functions trust their callers (validated at compile time). Boundary
functions (HTTP handlers, PubSub receivers) validate all input. This layered
approach provides strong safety guarantees without the overhead of validating
every function call.
Results
Since stabilizing the type system, the Prismatic Platform has seen a
measurable reduction in runtime type errors. Dialyzer catches an average
of 3-5 type mismatches per week during development that would have otherwise
reached production. The investment in comprehensive @spec annotations
pays for itself through faster debugging and more confident refactoring.