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
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
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 @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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With