Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can types and values be validated / enforced for Elixir structs?

Tags:

elixir

How can types and ranges of values be validated / enforced for Elixir Structs?

e.g. During Struct creation, throwing an error if invalid types/values are handed in

  • lat should be numerical and between -90.0 and +90.0
  • lon should be numerical and between -180.0 and +180.0

defmodule Location do
  @enforce_keys [:lat, :lon]
  defstruct lat: 0, lon: 0
end

There was some discussion here with @JoséValim, but not clear what the outcome was https://groups.google.com/forum/#!topic/elixir-lang-core/U_wdxEqWj_Y

like image 428
Tilo Avatar asked Jun 17 '18 16:06

Tilo


3 Answers

Whether you are looking for a lifetime guarding / type guarantees, it is impossible. Structs are bare maps underneath:

defmodule Location do
  @enforce_keys [:lat, :lon]
  defstruct lat: 0, lon: 0
end

loc = %Location{lat: 0, lon: 0}
is_map(loc) #⇒ true

even more, one might simply create a map with __struct__ key set to the atom, denoting the struct name, and voilà:

loc_str = %{__struct__: Location, lat: 0, lon: 0}
#⇒ %Location{lat: 0, lon: 0}

or use Kernel.struct/2, that does not check anything:

struct(Location, [lat: 0, lon: 0])
#⇒ %Location{lat: 0, lon: 0}

That said, one should not treat struct as a first-class citizen in Elixir types hierarchy. It’s a map with an additional field __struct__ set.

In Elixir we commonly use Typespecs and dialyzer for static code analysis for that purpose.

like image 68
Aleksei Matiushkin Avatar answered Oct 10 '22 15:10

Aleksei Matiushkin


Like @mudasobwa said, you can't make these guarantees at every step in Elixir since it's a dynamically typed language. But you can do it when building a struct in a helper function.

Here's an example from one of my projects:

defmodule Location do
  defstruct [:latitude, :longitude]

  @moduledoc "A struct representation of geo-coordinates"

  @latitude  %{max: +90,  min: -90}
  @longitude %{max: +180, min: -180}


  @doc "Return a new struct for given lat/longs"
  def new(long, lat) do
    validate_latitude!(lat)
    validate_longitude!(long)

    %Location{latitude: lat, longitude: long}
  end


  # Raise error if latitude is invalid
  defp validate_latitude!(lat) do
    case is_number(lat) && (lat <= @latitude.max) && (lat >= @latitude.min) do
      true -> :ok
      false ->
        raise Location.InvalidData, message: "Invalid value for Latitude"
    end
  end


  # Raise error if longitude is invalid
  defp validate_longitude!(long) do
    case is_number(long) && (long <= @longitude.max) && (long >= @longitude.min) do
      true -> :ok
      false ->
        raise Location.InvalidData, message: "Invalid value for Longitude"
    end
  end

end
like image 44
Sheharyar Avatar answered Oct 10 '22 15:10

Sheharyar


The Python people have the word "pythonic" to describe idiomatic Python code. I wish we had the same for Elixir ("elixirish"?). With this in mind, I tried to make @Sheharyar's solution a little less verbose and more functional, omitting all the conditional constructs:

defmodule Location do
  @moduledoc "A struct representation of geo-coordinates."

  defstruct [:longitude, :latitude]

  @doc "Return a new struct for given lat/longs"
  def new(long, lat) do
    valid_latitude?(lat)
    valid_longitude?(long)

    %Location{longitude: long, latitude: lat}
  end

  # Raise error if latitude is invalid
  defp valid_latitude?(lat) 
    when is_number(lat) and (-90 <= lat) and (lat <= +90),
    do: :ok

  defp valid_latitude?(_lat),
    do: raise "Invalid value for latitude; valid: -90..+90."

  # Raise error if longitude is invalid
  defp valid_longitude?(long)
    when is_number(long) and (-180 <= long) and (long <= +180),
    do: :ok

  defp valid_longitude?(_long),
    do: raise "Invalid value for longitude; valid: -180..+180."
end

Of course, there is almost always room for improvement:

defmodule Location do
  @moduledoc "A struct representation of geo-coordinates."

  defstruct [:longitude, :latitude]

  @doc "Return a new struct for given lat/longs"
  def new(long, lat) when
    is_number(long) and (long in -180..+180) and
    is_number(lat) and (lat in -90..+90),
  do: %Location{longitude: long, latitude: lat}

 def new(_, _),
   do: raise "Valid latitudes: -90..+90; valid longitudes: -180..+180."
end
like image 1
Reinhard Engel Avatar answered Oct 10 '22 15:10

Reinhard Engel