Spec
Data specification conformance and generation for Elixir
Install / Use
/learn @vic/SpecREADME
Spec <a href="https://travis-ci.org/vic/spec"><img src="https://travis-ci.org/vic/spec.svg"></a>
Spec is a data validation library for Elixir, inspired by [clojure.spec].
Like clojure.spec, this library does not implement a type system
and the data specifications created with it
are not useful for checking at compile time.
For that, use the [@spec typespecs][typespecs] Elixir builtin.
Spec calls cannot be used for pattern matching, nor in function head guards, because validating with Spec could involve calling some Elixir runtime functions which are not allowed inside a pattern match. If you are looking for a way to create composable patterns, take a look at [Expat][expat]. You can, for example, conform your data with Spec and then pattern match on the conformed value, using Expat to easily extract values from it.
Having said that, you can use Spec to validate that your data is of a given type, has certain structure, or satisfies some predicates. Spec supports all Elixir data types; that is, you can match on lists, maps, scalars, structs, and tuples. Maps, structs, and keyword lists can be checked for required keys.
Specs can be combined in various ways:
passed as arguments to other specs,
logically combined by using the and,or operators, and finally,
sequenced or alternated using regex (regular expression) operators.
You can validate your function arguments or return values
(it's all done at run-time);
see the RandomJane example below.
Finally, you can "exercise" a spec to get sample data that conforms to it.
Purpose
Spec's purpose is to provide a library for creating composable data structure specifications. Once you create an spec, you can match data with it, get human-readable descriptive messages, or programatically generate detailed errors if something inside of it does not conform to the specification. You can also exercise the spec, obtaining some random (but conformant) data. This can be used, for example, in tests.
Although Spec is heavily inspired by clojure.spec,
it does not attempt to exactly match the clojure.spec API.
Instead, Spec tries to follow Elixir/Erlang idioms,
producing a more familiar API for alchemists.
Installation
The Spec package
is published on Hex.
So, it can be installed by adding spec to your list of dependencies in mix.exs:
def deps do
[{:spec, "~> 0.1"}]
end
Usage
The rest of this document details the Spec API and example usage.
You can also take a look at the several [tests] for more examples.
In general, however, you only need to use the Spec module
once in each module that invokes one or more Spec predicates:
use Spec
Predicates
Predicates are Elixir's basic tool for validating data.
A predicate is a function (or macro) that takes some data
and returns either true or false.
For example, is_number/1 is a builtin predicate
that will return true when invoked like is_number(42).
Predicates can be used as specs by feeding them to Spec.conform(spec, data),
along with some data to check.
iex> use Spec
iex> conform(is_number(), 24)
{:ok, 24}
Note that conform/2 is a macro, rather than a function.
This lets it accept a specification (e.g., a predicate such as is_number())
with no arguments.
The macro provides its second argument (the data value)
as the first argument for the specification.
So, when performing the above validation, Spec will do 24 |> is_number().
The return value of a successful conform/2 call is an :ok tagged tuple,
even though is_number/1 actually returns a boolean (more on this later).
The second value of the tuple is the input data value.
You can use any Elixir or Erlang predicate (with any number of arguments) to conform data. Simply package all but the last argument in a tuple and use this as the second argument to the spec:
def tuple_sum({a, b}, c) when a + b == c, do: true
def tuple_sum(_, _), do: false
conform(tuple_sum(44), {12, 32})
# => {:ok, {12, 32}}
When used with this predicate, Spec will execute {12, 32} |> tuple_sum(44).
The returned data will be tagged with :ok or :error, as appropriate.
Actually, Spec adapts boolean predicates and makes them conform to the
Erlang idiom of returning tagged tuples
(e.g., {:ok, conformed}, {:error, mismatch}).
So, predicates are a particular case of data conformers in Spec.
Conformers
Conformers are functions that take data
and return {:ok, conformed} or {:error, %Spec.Mismatch{}},
where conformed is a "conformed" version of the input value.
Spec.Mismatch is just a data structure useful
for describing what went wrong and where the error occurred.
Spec.conform!/2 does not return a tuple,
but it raises an exception on error:
iex> conform!(is_number(), 42)
# => 42
iex> conform!(is_number(), "two")
** (Spec.Mismatch) `"two"` does not satisfy predicate `is_number()`
The conformed value does not necessarily need to equal the input data.
For example, the conformer could choose to transform the data
and return a destructured value.
Data structure specifications
Let's go back to conforming data with specifications and see how we can construct them.
Atoms, numbers, and binaries only match equal values:
iex> conform!(:hello, :hello)
:hello
But tuples and friends can specify their inner elements:
iex> conform!({is_atom(), is_number()}, {:ok, 22})
{:ok, 22}
iex> conform!({is_atom(), is_number()}, [:ok, 22])
** (Spec.Mismatch) `[:ok, 22]` is not a tuple
iex> conform!({is_atom(), is_binary()}, {:ok, 22})
** (Spec.Mismatch) `22` does not satisfy predicate `is_binary()`
at `1` in `{:ok, 22}`
So, using the tuple literal syntax, Spec will check that the value actually is a tuple of the same size, and that every element in it conforms to the corresponding spec.
Similarly, for list literals,
the spec [is_integer()] is a list containing a single integer value.
Naturally, the _ placeholder matches anything.
So, [{is_atom(), _}] could describe a keyword list with a single key:
iex> conform!([{is_atom(), _}], foo: 22)
[foo: 22]
You can also use the map literal syntax,
specifying more than one valid possibility.
(To check for the presence of map keys and which combinations
of keys are valid, see Spec.keys, below.)
iex> conform!(%{is_binary() => is_number()}, %{"hola" => 22})
%{"hola" => 22}
iex> conform!(%{is_binary() => is_binary(), is_atom() => is_binary()},
...> %{"hola" => "es", :hello => 44})
** (Spec.Mismatch) Inside `%{:hello => 44, "hola" => 22}`, one failure:
(failure 1) at `:hello`
`44` does not satisfy predicate `is_binary()`
Alternating specs
Inside a spec, the and/or operators are allowed.
For example, as previously shown on the data structure section,
you could use the {_, _} spec to check for a two-element tuple.
But for learning purposes, let's define it by combining two other specs.
We know Elixir's is_tuple/1 and tuple_size/1 could be handy here.
Remember that each spec expects its data as first argument,
so by anding them, you can conform like this:
iex> conform(is_tuple() and &(tuple_size(&1) == 2), {1, 2})
{:ok, {1, 2}}
iex> conform!(is_tuple() and &(tuple_size(&1) == 2), {1})
** (Spec.Mismatch) `{1}` does not satisfy predicate `&(tuple_size(&1) == 2)`
In a similar fashion, you can check against two specification alternatives:
iex> conform(is_atom() or is_number(), 20)
{:ok, 20}
However, it would be really handy to know which of the two specs matched 20.
For that, let's introduce the concept of tagged specs.
A tag can be combined with any spec; if the spec matches, a tagged tuple will be created for its conformed value. For example:
iex> conform!(:hello :: is_binary(), "world")
{:hello, "world"}
note: tagged specs use :: syntax familiar to Elixir [typespecs].
Tagged specs are the first example we have seen of a conformed value that is different from the original data given to the spec. In this case, the conformer creates a tagged tuple, wrapping data with a name.
This way, you can set a tag on any spec alternative:
iex> a = :foo
iex> b = :bar
iex> conform((a :: is_atom()) or (b :: is_number()), 20)
{:ok, {:bar, 20}}
In addition, using tags inside a list spec creates handy keywords:
iex> conform!([:a :: is_atom(), :b :: is_number()], [:michael, 23])
[a: :michael, b: 23]
Finally, in Spec you can use the Elixir pipe to feed the conformed value into any function. The piped function will be called only if the data has been verified to conform with the preceding specification.
Try not to abuse this; it's better to create a function and have at most a single pipe. The purpose of piped specs is so that you can create functions that work on already defined predicates and return possibly different conformed values.
``
