Search code examples
elixirdialyzertypespec

How to make a module depend on a behaviour?


Background

I am trying to write some code to exemplify the power of behaviours in Elixir.

As a result, I am trying to implement this diagram:

Basically, I have a Car module, which depends on a behaviour (interface) Car.Brake. There are two possible implementations of this behaviour, the Car.Brake.ABS and Car.Brake.BBS respectively.

Code

The way I modeled this concept into code is as follows:

lib/brake.ex:

defmodule Car.Brake do
  @callback brake :: :ok
end

lib/brake/abs.ex

defmodule Car.Brake.Abs do
  @behaviour Car.Brake

  @impl Car.Brake
  def brake, do: IO.puts("Antilock Breaking System activated")
end

lib/brake/bbs.ex

defmodule Car.Brake.Bbs do
  @behaviour Car.Brake

  @impl Car.Brake
  def brake, do: IO.puts("Brake-by-wire activated")
end

lib/car.exs

defmodule Car do
  @type brand :: String.t()
  @type brake_system :: module()
  @type t :: %__MODULE__{
          brand: brand(),
          brake_system: brake_system()
        }

  @enforce_keys [:brand, :brake_system]
  defstruct [:brand, :brake_system]

  @spec new(brand(), brake_system()) :: t()
  def new(brand, brake) do
    %__MODULE__{
      brand: brand,
      brake_system: brake
    }
  end

  @spec brake(t()) :: :ok
  def brake(car), do: car.brake_system.brake()

  @spec change_brakes(t(), brake_system()) :: t()
  def change_brakes(car, brake), do: Map.put(car, :brake_system, brake)
end

Objective

The idea here is that I can create a Car and decide (and even change) which breaking system is used at runtime:

> c = Car.new("Honda", Car.Brake.Bbs)
%Car{
  brand: "Honda",
  breaking_system: Car.Brake.Bbs
}

c = Car.change_brakes(c, Car.Brake.Abs)

%Car{
  brand: "Honda",
  breaking_system: Car.Brake.Abs
}

Problem

So far so good. But now comes the problem. I am using dialyzer to check the types. And while the above code sample will not trigger an error in the compiler (nor in dialyzer) the following code sample will also not trigger any errors, while still being obviously wrong:

c = Car.new("honda", Car.WingsWithNoBreaks)

The issue here is that Car.WingsWithNoBrakes does not implement the Car.Brakes behaviour, but no one complains because of my lax typespec @type brake_system :: module()

Questions

With this in mind, I have several questions:

  1. How can I make my typespec more specific, to verify the module being passed actually implements the behaviour?
  2. If this is not possible, is there a more idiomatic way of specifying this dependency clearly?
  3. What is the idiomatic way in elixir to specific that “module A” depends on an implementation of “behaviour B” ?

My current understanding after asking in the community is that none of this is possible in Elixir, but I would like confirmation/refutation of my belief.


Solution

  • My answer

    As others have pointed out, my main objective of having more specific specs is not possible using Elixir nor dialyzer.

    You can try a possible workaround, as mentioned by @Aleksei matiushkin and use nimble options but this goes against the core of the idea, which is to use typespecs and to help the compiler identify possible issues.

    Why

    There is a great collection of posts from the community that specify why this is not possible in Elixir.

    To quote some of them:

    Ultimately the assurances of statically typed languages cannot be given in a dynamic system, because the very system itself has the potential to change. So to reiterate what others have said, there isn’t really a way to achieve what you’re wanting to do, and most of the things that might seem to help give these guarantees, will only give you false confidence.

    in Elixir, it’s possible for the very behaviour itself to change at runtime! So how can a compiler give you any assurances against a system which is completely malleable after the point which it would give those assurances.

    by Pejrich.

    Other users also completed the picture, by providing additional reasoning on why none of this is currently possible, not is it likely to be:

    So all in all: (...)

    • If attempted it would be full of footguns and false positives.
    • Even if you do what can feasably be done today you’re still checking just a subset of error cases, so you cannot get rid of handling error cases at runtime anyways
    • Some refactoring like extracting the module name to a variable or even another function makes this problem vastly more difficult outside of a typesystem, given how common that is it’s questionable if the effort to implement the above is worthwhile at all

    by LostKobrakai

    TL;DR

    So all in all, the main reason such a static check like this is not possible, is due to the dynamic nature of the language. You can use some partial workarounds, but this too have to be done at runtime which then raises the question of how the code should behave in such a scenario.