Back to Blog
Architecture March 17, 2026 | 11 min read

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:


  • Functions that claim to return {:ok, t()} but have code paths returning
  • nil

  • Pattern matches that can never succeed based on the input type
  • Callback implementations that do not match the behaviour's spec

  • 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:


    LevelMechanismWhen Checked

    |-------|-----------|-------------|

    Compile-time@enforce_keys, Dialyzermix compile / mix dialyzer Startup-timeConstructor validation, ETS loadingApplication boot RuntimeBoundary validation, changeset checksRequest handling

    Internal 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.


    Tags

    types dialyzer spec api safety