DRYing up typespec return types without obscuring internal values

I have the following functions that wraps my ecto queries in :ok|:error tuples, with the accompanying specs

  @type fetch_one_result :: {:ok, Ecto.Schema.t()} | {:error, :not_found | :too_many_results}
  @spec fetch_one(Ecto.Queryable.t()) :: fetch_one_result()
  @doc """
  Get one row from repo, wraps result in {:ok, _} or {:error, _} tuple.

  {:ok, result} when one row is returned
  {:error, :not_found} when no rows are returned
  {:error, :too_many_results} when more than one row is returned
  """
  def fetch_one(query) do
    ...
  end

  @spec fetch_one_by(Ecto.Queryable.t(), [{atom(), any()}]) :: fetch_one_result()
  @doc """
  Get one row from repo, by key-values provided, wraps result in {:ok, _} or {:error, _} tuple.

  {:ok, result} when one row is returned
  {:error, :not_found} when no rows are returned
  {:error, :too_many_results} when more than one row is returned
  """
  def fetch_one_by(schema, [{key, value}]) do
    ...
    |> where(^[{key, value}])
    |> fetch_one()
  end

fetch_one_by delegates to fetch_one. I didn’t want to have to keep the two specs in sync manually, so I define the return type (fetch_one_result) and use that.

My issue is that my LSP/tooling (correctly) reports the return type as :: fetch_one_result, not the actual tuple values. This isn’t terrible but it’s a bit of a UX/DX pain.

Is there a way to:

  1. Not have to duplicate my tuple definitions and
  2. Have tooling recognize the “through” typing?

I tried a simple @fetch_one_result {:ok.. but it fails because I have the pipe in there for different error types, it’s not valid “code”.

Are you planning to change them often? DRY is a valuable instinct, but IMO it’s mostly about preventing future maintenance pain - and these specs don’t seem like they’d ever change…

No not really, it was more a question on if it was possible I guess.

Indeed I half think that maintaining the specs independently means any changes to fetch_one will force you to least think about fetch_one_by and make sure it is ok returning the same types.

Yes, this is possible with a bit of metaprogramming.

defmodule M do
  @fetch_one_result quote(do: {:ok, any()} | {:error, :not_found | :too_many_results})

  @spec foo :: unquote(@fetch_one_result)
  def foo, do: {:ok, 42}

  @spec bar :: unquote(@fetch_one_result)
  def bar, do: {:error, :not_found}
end

Resulting in


I would recommend documenting this new type with @typedoc though.

3 Likes