Background
Recently I have discovered this notion of “zero cost type wrappers”. Basically what this means is that you can create a new type by wrapping a primitive type (and the cost of doing this is low to non-existent). This new type would serve as an additional layer of abstraction and prevent certain categories of bugs at compile time.
For example, let’s assume we have this function (assume we have an Artist
struct):
@spec new(artist :: String.t, country :: String.t, genre :: String.t) :: Artist.t
def new(n, c, g) do
%Artist{
name: n, country: c, genre: g
}
end
Now obviously I added the specs here for help. But you will notice that everything is String.t
. This basically means I can incorrectly invoke this function:
MyModule.new("U.S.", "Metallica", "Heavy Metal") # name and country and swapped
The compiler would not complain.
NewType abstraction
To solve this issue, some people came up with this notion of wrapping primitive types into an abstraction. If you are from Scala you may know this as “Zero-cost abstraction for NewTypes”, if you are from Rust you may know it as the NewType Pattern and so on (this is a feature present in many languages these days).
scala code
opaque type Location = String
object Location{
def apply(value: String): Location = value
extension(a: Location) def name: String = a
}
This would create a new type called Location
that wraps the String
primitive type.
In Elixir, our function’s signature would now be:
@spec new(artist :: String.t, country :: Location.t, genre :: String.t) :: Artist.t
(you can also do the same for genre
)
Elixir NewType wrappers?
Now, using the power of typespecs
I could do something like:
@type location :: String.t()
And use it in my specs. But this would serve merely as documentation and would prevent no types of errors whatsoever.
The closest thing that comes to my mind, would be to define a struct
:
defmodule Location do
defstruct [:name]
@type t :: %__MODULE__{name: String.t()}
@spec new(name :: String.t()) :: __MODULE__.t()
def new(name), do: %__MODULE__{name: name}
end
Ignoring the boilerplate code (we can just create a macro for that!) I think this is the closest I can get to having something like the NewType abstraction.
This would allow us to invoke the function like this:
MyModule.new("Metallica", Location.new("U.S."), Genre.new("Heavy Metal"))
We can’t swap parameters and have thus eliminated a category of errors. Further more, we did this at compile time.
Would it be zero-cost? I don’t think so, since I am replacing a String.t
with a map
that has 1 key. The overhead would probably be minimal, but I don’t think I could call it zero cost.
Questions
- How would you implement this abstraction in Elixir?
- Are there any optimizations one could do here?
- Is it possible to have a compile time check that prevents this category of errors using
typespecs
only? (I don’t think so, but please feel free to prove me wrong)