Typespec

quality

Typespec

Definition

Typespecs are Elixir's annotation system for declaring types and function signatures at the module level. The @spec attribute documents a function's parameter types and return type; @type and @typep define public and private named types; @callback declares the type signatures required by a Behaviour. While Elixir remains dynamically typed at runtime -- the BEAM virtual machine does not enforce type annotations during execution -- typespecs enable Dialyzer to perform static analysis at compile time, catching type mismatches, unreachable code, and contract violations without false positives through its "success typing" approach.

Typespecs serve a dual purpose: they are machine-readable contracts consumed by static analysis tools, and they are human-readable documentation rendered by ExDoc in generated API documentation. This dual role means that comprehensive typespec coverage simultaneously improves code safety (through Dialyzer analysis) and developer experience (through clear API documentation). Unlike type systems in languages like Haskell or Rust, Elixir typespecs are optional and advisory -- they guide tooling without constraining the runtime, providing a pragmatic middle ground between fully dynamic and fully static typing.

The typespec system supports a rich vocabulary of built-in types (atoms, integers, floats, binaries, lists, tuples, maps, structs, pids, references, ports), union types (String.t() | atom()), parameterized types (list(integer())), and special types like no_return() for functions that always raise or loop. Custom types compose these primitives into domain-specific vocabulary that makes function signatures self-documenting.

Context in Prismatic

Typespec coverage is one of the 13 quality domains tracked to zero violations in the Prismatic Platform. The NO MERCY doctrine mandates @spec annotations on all public functions across all 90 umbrella applications. Missing typespecs are counted as QDP (Quality Debt Points) and blocked by quality gates -- the platform currently maintains 0 QDP across all domains. The mix quality.gates command validates typespec coverage as part of the Clean Run standard, and CI pipelines reject code that introduces public functions without corresponding @spec annotations.

Dialyzer analysis depends on comprehensive typespecs for accurate cross-module type checking. The platform's PLT (Persistent Lookup Table) cache includes type information for all 90 apps and their dependencies, enabling Dialyzer to detect type mismatches across module boundaries. Custom types defined in prismatic_storage_core (like entity_id(), confidence_score(), risk_rating()) propagate through the entire codebase, creating a consistent type vocabulary across all platform components.

Type Declaration Syntax

Elixir provides three module attributes for type declarations:

defmodule PrismaticPerimeter.Types do
  @moduledoc "Shared type definitions for the Perimeter domain."

  # Public type -- visible in documentation and to Dialyzer
  @type security_grade :: :A | :B | :C | :D | :F

  @type security_rating :: %{
    grade: security_grade(),
    score: 300..900,
    confidence: float(),
    assessed_at: DateTime.t()
  }

  @type asset_type :: :domain | :ip | :certificate | :cloud_resource | :service

  @type discovery_result :: {:ok, [asset()]} | {:error, discovery_error()}

  @type asset :: %{
    type: asset_type(),
    identifier: String.t(),
    risk_score: float(),
    metadata: map()
  }

  # Private type -- only visible within this module
  @typep discovery_error :: :timeout | :dns_failure | :rate_limited | {:http_error, pos_integer()}

  # Opaque type -- callers cannot pattern match on internal structure
  @opaque rating_state :: %__MODULE__{
    assets: [asset()],
    cache: map(),
    last_scan: DateTime.t() | nil
  }
end
AttributeVisibilityUse Case
@typePublicDomain types used across modules
@typepPrivateImplementation types within a module
@opaquePublic name, hidden structureTypes where callers should not depend on internals

Function Specifications

The @spec attribute declares the type contract for a function:

defmodule PrismaticPerimeter.SecurityRating do
  alias PrismaticPerimeter.Types

  @spec calculate(map()) :: {:ok, Types.security_rating()} | {:error, atom()}
  def calculate(%{} = assets) do
    score = compute_score(assets)
    grade = score_to_grade(score)
    {:ok, %{grade: grade, score: score, confidence: 0.95, assessed_at: DateTime.utc_now()}}
  end

  def calculate(_invalid), do: {:error, :invalid_assets}

  @spec score_to_grade(300..900) :: Types.security_grade()
  defp score_to_grade(score) when score >= 850, do: :A
  defp score_to_grade(score) when score >= 700, do: :B
  defp score_to_grade(score) when score >= 550, do: :C
  defp score_to_grade(score) when score >= 400, do: :D
  defp score_to_grade(_score), do: :F

  # Multiple clauses with different arities
  @spec discover(String.t()) :: Types.discovery_result()
  @spec discover(String.t(), keyword()) :: Types.discovery_result()
  def discover(domain, opts \\ []) do
    # ...
  end

  # Function that never returns normally
  @spec raise_invalid!(term()) :: no_return()
  def raise_invalid!(value) do
    raise ArgumentError, "Invalid value: #{inspect(value)}"
  end
end

Callback Specifications

Behaviours use @callback to declare the type contracts that implementing modules must satisfy:

defmodule PrismaticStorage.Adapter do
  @moduledoc "Behaviour for storage adapter implementations."

  @type key :: String.t()
  @type value :: term()
  @type error :: {:error, :not_found | :connection_failed | :timeout}

  @callback get(key()) :: {:ok, value()} | error()
  @callback put(key(), value()) :: :ok | error()
  @callback delete(key()) :: :ok | error()
  @callback list(prefix :: String.t()) :: {:ok, [key()]} | error()

  # Optional callback with default implementation
  @callback health_check() :: :ok | {:error, term()}
  @optional_callbacks health_check: 0
end

When a module uses @behaviour PrismaticStorage.Adapter, Dialyzer verifies that the implementing module's function specs are compatible with the callback declarations. Missing callbacks produce compilation warnings; incompatible types produce Dialyzer warnings.

Built-in Type Reference

Elixir's typespec system provides a comprehensive set of built-in types:

CategoryTypesExamples
Basicatom(), integer(), float(), boolean():ok, 42, 3.14, true
Binarybinary(), String.t(), bitstring()"hello", <<1, 2, 3>>
Collectionslist(), map(), tuple(), keyword()[1, 2], %{}, {:ok, v}
Processpid(), reference(), port()self(), make_ref()
Structs%Module{}, struct()%DateTime{}, %URI{}
Functions(arg -> ret), fun()(integer() -> boolean())
Specialterm(), any(), no_return(), as_boolean()Catch-all, never returns
Ranges1..100, non_neg_integer(), pos_integer()Numeric constraints
Uniontype1 | type2String.t() | atom()
Literal:specific_atom, 1, trueExact value types

Dialyzer Integration

Typespecs feed directly into Dialyzer's static analysis pipeline. Dialyzer uses "success typing" -- rather than proving a program correct, it proves that certain call patterns will definitely fail. This eliminates false positives while still catching genuine type errors.

Source Code + @spec annotations
         |
         v
  Compilation to BEAM bytecode
         |
         v
  PLT (Persistent Lookup Table) construction
         |
         v
  Dialyzer analysis (success typing)
         |
         v
  Warnings: type mismatches, unreachable code, missing returns
Dialyzer WarningCauseTypespec Fix
no_returnFunction always raises/loopsAdd @spec func() :: no_return()
invalid_contractSpec contradicts implementationAlign @spec with actual return types
call_without_opaqueOpaque type accessed directlyUse module's public API
missing_returnNot all clauses covered in specAdd union type for all return paths
overlapping_contractMultiple specs overlapConsolidate into single spec with unions

Documentation Integration

ExDoc renders typespecs in generated documentation, making them the primary API reference:

defmodule PrismaticPerimeter.Scanner do
  @moduledoc """
  Scans domains for attack surface assets.
  """

  @doc """
  Discovers all externally visible assets for a domain.

  Returns a list of discovered assets with risk scores and metadata.
  Requires network access to DNS resolvers and certificate transparency logs.

  ## Parameters

    * `domain` - The target domain to scan (e.g., "example.com")
    * `opts` - Scan options (see below)

  ## Options

    * `:depth` - Maximum subdomain enumeration depth (default: 3)
    * `:timeout` - Per-check timeout in milliseconds (default: 30_000)

  """
  @spec discover(String.t(), keyword()) :: {:ok, [map()]} | {:error, atom()}
  def discover(domain, opts \\ []) do
    # Implementation
  end
end

The rendered documentation shows the function signature with linked types, enabling developers to click through to type definitions and understand the complete API contract without reading source code.

Gradual Typing Strategy

Typespecs support a gradual adoption strategy where teams add type annotations incrementally:

PhaseCoverageDialyzer ImpactPrismatic Status
Phase 1Public API functions onlyBasic cross-module checkingComplete
Phase 2All public + key private functionsDeep intra-module analysisComplete
Phase 3Custom domain types (@type)Semantic type vocabularyComplete
Phase 4Behaviour callbacks (@callback)Contract verificationComplete
Phase 5100% coverage including privateFull codebase analysisEnforced

The Prismatic Platform operates at Phase 5 -- every public function has a @spec, all behaviours have @callback declarations, and domain types are defined in shared type modules. This provides maximum Dialyzer coverage and ensures API documentation is always complete and type-accurate.

  • Dialyzer - Static analysis tool that consumes typespecs for type checking
  • Behaviour - Module contracts using @callback type declarations
  • QDP - Quality metric tracking missing typespec annotations
  • Clean Run - Zero-warning standard including typespec completeness
  • ExUnit - Test framework complementing typespec static analysis
  • Code Coverage - Runtime coverage complementing compile-time type analysis
  • BEAM - Virtual machine whose bytecode Dialyzer analyzes
  • Mix - Build tool running Dialyzer via mix dialyzer
  • Pattern Matching - Runtime type discrimination complementing typespecs
  • Immutability - Data model enabling reliable type inference

See Also


Connect & Contribute

Created by Tomáš Korcak (korczis) | Open Source under GHL