If you are up to a declarative approach and dependency inversion towards your application core’s data types, you can use Domo library. That generates validators and constructor functions from a t() type spec of the struct, and the type spec is validated for syntax correctness by elixir during the compilation.
So the request can be accepted into the struct that looks like the following:
defmodule Request do
use Domo, ensure_struct_defaults: false
defstruct [:email, :password, :date_of_birth]
@type t :: %__MODULE__{
email: email(),
password: password(),
date_of_birth: Date.t() | nil
}
@type email :: String.t()
precond email: &String.match?(&1, ~r|.+\@.+\..+|)
@type password :: String.t()
precond password: &String.length(&1) > 7
# Domo adds new/1, new!/1, ensure_type/1, ensure_type!/1 here automatically
end
And the validation can be done like that:
iex(1)> Request.new(%{email: "user@test.com", password: "some_password"})
{:ok, %Request{date_of_birth: nil, email: "user@test.com", password: "some_password"}}
iex(2)> Request.new(%{email: "usertestcom", date_of_birth: "none"})
{:error,
[
password: "Invalid value nil for field :password of %Request{}. Expected the value matching the <<_::_*8>> type.",
email: "Invalid value \"usertestcom\" for field :email of %Request{}. Expected the value matching the <<_::_*8>> type. And a true value from the precondition function \"&String.match?(&1, ~r|.+\\\\@.+\\\\..+|)\" defined for Request.email() type.",
date_of_birth: "Invalid value \"none\" for field :date_of_birth of %Request{}. Expected the value matching the %Date{} | nil type."
]}
The dependency inversion can be done by sharing email() and password() in the shared module to have the same rules for emails and passwords across the whole app.
Domo plays nicely with Ecto schemas because they are structs too. See Domo.Changeset for this kind of integration.


















