Extracting repeated type specs to module-level `@type` declarations

almirsarajcic

almirsarajcic

18 hours ago

Tired of Dialyzer warnings and repetitive type declarations? Clean up your specs by extracting repeated types to the module level. Your code becomes more readable and type changes only need updating in one place.

# Before: Repetitive and hard to maintain
defmodule MyApp.Products do
  alias MyApp.Accounts.User
  alias MyApp.Products.Product

  @spec create_product(User.t(), map(), String.t()) ::
    {:ok, Product.t()} | {:error, Ecto.Changeset.t()}
  def create_product(user, attrs, category), do: # Implementation

  @spec update_product(Product.t(), map(), User.t()) ::
    {:ok, Product.t()} | {:error, Ecto.Changeset.t()}
  def update_product(product, attrs, user), do: # Implementation

  @spec list_products_for_user(User.t(), map()) :: [Product.t()]
  def list_products_for_user(user, filters), do: # Implementation
end

# After: Clean and maintainable
defmodule MyApp.Products do
  alias MyApp.Accounts.User
  alias MyApp.Products.Product

  @type attrs :: map()
  @type product :: Product.t()
  @type result :: {:ok, product()} | {:error, Ecto.Changeset.t()}
  @type user :: User.t()

  @spec create_product(user(), attrs(), String.t()) :: result()
  def create_product(user, attrs, category), do: # Implementation

  @spec update_product(product(), attrs(), user()) :: result()
  def update_product(product, attrs, user), do: # Implementation

  @spec list_products_for_user(user(), attrs()) :: [product()]
  def list_products_for_user(user, filters), do: # Implementation
end

Extract semantic types for better documentation:

defmodule MyApp.Orders do
  alias MyApp.Accounts.User
  alias MyApp.Products.Product

  # Extract complex and repeated types
  @type order_attrs :: %{
    items: [%{product_id: product_id(), quantity: quantity()}],
    user_id: user_id()
  }
  @type price_cents :: non_neg_integer()
  @type product_id :: non_neg_integer()
  @type quantity :: pos_integer()
  @type user_id :: non_neg_integer()

  @spec create_order(User.t(), order_attrs()) ::
    {:ok, Order.t()} | {:error, Ecto.Changeset.t()}
  def create_order(user, attrs) do
    # Implementation
  end

  @spec calculate_total([{Product.t(), quantity()}]) :: price_cents()
  def calculate_total(items) do
    # Implementation
  end
end

For aliased modules, combine with type extraction:

defmodule MyApp.Billing do
  alias MyApp.Accounts.User
  alias MyApp.Orders.Order
  alias MyApp.Payments.Transaction

  # Module types - use the alias
  @type order :: Order.t()
  @type transaction :: Transaction.t()
  @type user :: User.t()

  # Domain types
  @type amount :: Decimal.t()
  @type currency :: :usd | :eur | :gbp

  @spec charge_user(user(), order(), currency()) ::
    {:ok, transaction()} | {:error, String.t()}
  def charge_user(user, order, currency) do
    # Implementation
  end
end

Best practices:

  • Extract any type that appears 2+ times in specs
  • Group all @type definitions at the module top, after aliases
  • Order types alphabetically for easy scanning
  • Create semantic aliases (user_id vs generic integer)
  • For opaque external types, reference the struct: @type session :: %PhoenixTest.Session{}

This pattern makes your code self-documenting and helps Dialyzer provide better error messages when types don’t match.