Polymorphic types for dialyzer in Elixir

Background

I have a struct called MyApp.Result that is basically a representation of a Result Monad. This struct aims to be a formal structural representation for operation success and error:

defmodule MyApp.Result do

  @enforce_keys [:type]
  defstruct type: nil,
            result: nil,
            error_reason: nil,
            error_details: nil,
            input_parameters: []

  
  @type type :: :ok | :error
  @type result :: any()
  @type reason :: atom() | nil
  @type details :: any()
  @type params :: [any()]

  @type t() :: %__MODULE__{
          type: type(),
          result: result(),
          error_reason: reason(),
          error_details: details(),
          input_parameters: params()
        }

  @spec ok :: __MODULE__.t()
  def ok, do: %__MODULE__{type: :ok}


  @spec ok(result()) :: __MODULE__.t()
  def ok(result), do: %__MODULE__{type: :ok, result: result}

  @spec error(reason()) :: __MODULE__.t()
  def error(reason), do: %__MODULE__{type: :error, error_reason: reason}

  @spec error(reason(), details()) :: __MODULE__.t()
  def error(reason, details) do
    %__MODULE__{type: :error, error_reason: reason, error_details: details}
  end

  @spec error(reason(), details(), params()) :: __MODULE__.t()
  def error(reason, details, input) do
    %__MODULE__{
      type: :error,
      error_reason: reason,
      error_details: details,
      input_parameters: input
    }
  end

Problem

When showing this to one of my colleagues, he made a good point:

I see what you are trying to do here, but when I see a MyApp.Result struct I have to check the code to know what is inside of the result field in case of success. For me this is just another layer that hides things and it does not make clear what the operation returns if it succeeds.

Which to be fair, I think is a good point. Result Monads hide the result of computations until you need them. But I do think there is a better way, a way where we can still have a Result Monad that makes explicit the type of the result field.

Polymorphic types with dialyzer

I believe the solution to my problem could be in dialyzer’s polymorphic types. To quote an article:

From Learn You Some Erlang
When I said we could define a list of integers as [integer()] or list(integer()), those were polymorphic types. It’s a type that accepts a type as an argument.

To make our queue accept only integers or cards, we could have defined its type as:

-type queue(Type) :: {fifo, list(Type), list(Type)}.
-export_type([queue/1]).

So now I know this is possible in erlang. If it is possible there, it should be possible in Elixir as well.

Error

So I have changed my code to the following:

@enforce_keys [:type]
  defstruct type: nil,
            result: nil,
            error_reason: nil,
            error_details: nil,
            input_parameters: []

  @type type :: :ok | :error
  @type result :: Type
  @type reason :: atom() | nil
  @type details :: any()
  @type params :: [any()]

  @type t(Type) :: %__MODULE__{
          type: type(),
          result: result(),
          error_reason: reason(),
          error_details: details(),
          input_parameters: params()
        }

  @spec ok :: BusyBee.Wrappers.FFmpeg.Result.t(nil)
  @spec ok :: __MODULE__.t()
  def ok, do: %__MODULE__{type: :ok}

  @spec ok(result()) :: __MODULE__.t(Type)
  def ok(result), do: %__MODULE__{type: :ok, result: result}

However this breaks. I don’t know how to represent Polymorphic types in Elixir using dialyzer.

Question

How can I fix this code so my dialyzer knows Result is a polymorphic type?

  @type type :: :ok | :error
  @type reason :: atom() | nil
  @type details :: any()
  @type params :: [any()]

  @type t(type) :: %__MODULE__{
          type: type(),
          result: type,
          error_reason: reason(),
          error_details: details(),
          input_parameters: params()
        }

  # here we know the value of result will be `nil`. Not sure how useful this is though.
  @spec ok :: t(nil)
  def ok, do: %__MODULE__{type: :ok}

  # This cannot magically guess the type of the input, so it'll be just `term()`
  @spec ok(result) :: t(result) when result: term()
  def ok(result), do: %__MODULE__{type: :ok, result: result}

Typespecs do not magically know what Type is. Keep in mind that variables in erlang are usually capitalized, while they’re lowercase in elixir. The same applies to typespecs.

3 Likes

I see, that makes sense. So now when I want to create use this as a spec I can:

alias MyApp.Result

@spec my_function_that_returns_result_monad :: Result.t(float())
def my_function_that_returns_result_monad do
  Result.ok(0.0)
end

And this would work right?
Thanks !

Usually? :face_with_monocle:

1 Like

Likely more like: I’m confident, but not 100% sure it needs to be this way :smiley:

1 Like

It’d be an atom otherwise.

2 Likes