Exchema
Exchema is a library to define, validate and coerce data. It allows you to check the type for a given value at runtime (it is not static type checking).
It uses the idea of refinement types, in which we have a global type (which all values belong) and can refine that type with the use of predicates.
Types
A type can be:
- the global type
:any - a type reference such as
Exchema.Types.String - a type refinement such as
{:ref, :any, length: 1} - a type application (for parametric types) such as
{Exchema.Types.List, Exchema.Types.String}
Checking types
Exchema ships with some predefined types that you can check using
Exchema.is?/2
iex> Exchema.is?("1234", Exchema.Types.String)
true
iex> Exchema.is?(1234, Exchema.Types.String)
false
iex> Exchema.is?(1234, Exchema.Types.Integer)
true
There is also the global type :any
iex> Exchema.is?("1234", :any)
true
iex> Exchema.is?(1234, :any)
true
Parametric types
A type can be specialized, e.g. lists can have an inner type specified, so
{Exchema.Types.List, Exchema.Types.Integer} represents a list of integers.
In the case of list, you can just use and not specify it directly, so
Exchema.Types.List is a list of elements of any type, or
{Exchema.Types.List, :any}.
Some types can have multiple parameters, e.g. a map.
{Exchema.Types.Map, {Exchema.Types.String, Exchema.Types.Integer}} represents
a map from strings to integer.
Types with 0 params can be represented just by the module name.
Types with 1 param can be represented by a tuple {type, argument}
Types with N params can be represented by a tuple {type, arguments} where
arguments is a tuple with N elements.
Type refinement
This is the core of our system, it allows to create more specialized types starting
from the global type :any. In fact, all the types that ships with Exchema are
just refinements from other types. (You will see it more in defining your own types)
A refined type is a 3-tuple with the :ref keyword, the source type and a list of
2-tuples containing the predicate reference and the predicate argument
A predicate is basically a reference to a function and one argument.
# source type==|| ||==predicate reference
# || ||
# ref keyword==|| || || ||==predicate argument
# \/ \/ \/ \/
iex> Exchema.is?({:ref, :any, length: 1 }, [])
false
iex> Exchema.is?({:ref, :any, length: 1}, ["element"])
true
Predicate
A predicate in the end is just a function with 2 arguments, the first being the value
to check and the second is an arbitrary argument. It should return either
true or :ok to represent a valid value and false, {:error, error} or
[{:error, error}] to represent an invalid value.
Predicate References
Here :length is the reference to a function (which comes from a predicate library).
By default, Exchema ships with a predicate library which you can check at Exchema.Predicates
and that module exposes some functions, e.g. length/2.
You can also reference a function directly in the form of {module, function_name}, so actually
:length is the same as {Exchema.Predicates, :length} (expect if you override the default lib).
Defining your own type
In the type refinement section you "already defined" a new type, but you didn't give it a name.
When refining :any with length: 1 predicate you created a new type.
To be able to reference it by name, you need to define a module.
A type reference is just a module that defines a function ___type__/1.
That function will receive as the argument a tuple with the arguments passed when referencing the type.
If you want to define a type without params, you can do it by defining a module like
defmodule MyType do
# we can ignore the argument because it is not a parametric type
def __type__(_) do
{:ref, :any, length: 1}
end
end
What happens is that when the type is trying to check against a type MyType, it will
call MyType.__type__({}) and see the response. This is the type resolving procedure.
Notice that an empty tuple is passed (this is because you referenced it as MyType),
with that being said Exchema.is?(val, MyType) is the same as Exchema.is?(val, {MyType, {}}).
If you want to define a parametric type you can just accept a tuple with size 1 (or more if you want)
defmodule MyType do
# Here we are defining a type that can receive 1 or 0 params.
# If no params are given, it is a type with length 1, otherwise it has an arbitrary length.
def __type__({}), do: __type__({0})
def __type__({length}) do
{:ref, :any, length: length}
end
end
Now you can use that type as {MyType, 10} or {MyType, {10}}, which are equivalent.
Struct Types
A lot of data types in elixir are just structs. That's why we have an special type (and predicates) to treat structs.
Let's say we want to represent a geographic location, a coordinate. We can use the type Exchema.Types.Struct.
This type receives two arguments, a module (the struct module) and a list of field types, which is a list
of tuples containing the key and the expected type, e.g. {:long, Exchema.Types.Float}. But you can use
elixir syntax sugar to make it prettier, like the example below.
defmodule Coord do
defstruct [:long, :lat]
def __type__(_) do
{Exchema.Types.Struct, {__MODULE__, [
long: Exchema.Types.Number,
lat: Exchema.Types.Number
]}}
end
end
iex> Exchema.is?(%{}, Coord)
false
iex> Exchema.is?(%Coord{long: "", lat: ""}, Coord)
false
iex> Exchema.is?(%Coord{long: 10.0, lat: 10.0}, Coord)
true
Exchema.Struct
However, defining struct types can be really cumbersome and thats why we have
the Exchema.Struct module which you can use as
defmodule Coord do
use Exchema.Struct, fields: [
long: Exchema.Types.Number,
lat: Exchema.Types.Number
]
end
And you can alias Exchema.Types to have smaller type definitions.
defmodule Coord do
alias Exchema.Types, as: T
use Exchema.Struct, fields: [
long: T.Number,
lat: T.Number
]
end
Defining your own predicate
Defining a predicate is as simple as defining a function.
Let's say we want to use uuid lib to validate wheter or not a string
is a UUID.
defmodule MyPredicates do
def uuid(value, _) when is_binary(value) do
case UUID.info(value) do
:ok ->
:ok
_ ->
{:error, :not_a_valid_uuid}
end
end
def uuid(_, _), do: {:error, :not_a_valid_uuid}
end
Now we can use that in our types
defmodule MyUUID do
def __type__(_) do
{:ref, Exchema.Types.String, [
{{MyPredicates, :uuid}, true}
]}
end
end
Configuring your predicate library
You can configure Exchema to include your predicate library by adding
config :exchema,
predicates: [MyPredicates]
Now you can use the function name directly, so you can rewrite your type to be:
defmodule MyUUID do
def __type__(_) do
{:ref, Exchema.Types.String, uuid: true}
end
end
And then you can check the uuid
iex> Exchema.is?("randomstring", MyUUID)
false
iex> Exchema.is?("f4fd18af-1e8d-4262-a655-c1fa83ae9162", MyUUID)
true
Coercion
This should probably move to another library, but for now it is bundled here.
Exchema.Coercion can receive some input and coerce to a specific type.
iex> Exchema.Coercion.coerce("2018-01-01", Exchema.Types.Date)
~D[2018-01-01]
defmodule MyStruct do
use Exchema.Struct, fields: [
foo: Exchema.Types.Integer,
bar: Exchema.Types.Date
]
end
iex> Exchema.Coercion.coerce(%{"foo" => 1, "bar" => "2018-01-01"}, MyStruct)
%MyStruct{
foo: 1,
bar: ~D[2018-01-01]
}
iex> Exchema.Coercion.coerce(["1", 2, 3.0], {Exchema.Types.List, Exchema.Types.Integer})
[1,2,3]
Installation
Add exchema to your list of dependencies in mix.exs:
def deps do
[
{:exchema, "~> 0.2.0"}
]
end
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/exchema.