How to build an Elixir validator from scratch

Image not Found

Validation is a must have part of web application. You have to validate request parameter before processing, you validate data before inserting to database, and many more.

Normally, I use Ecto.Changeset to do validation job. But it comes with changeset, I have to build schema, changeset then do the validation. Sometime you just don’t need too much thing like that.

So today we are going to build a simple validation module to use without changeset, or in some project you don’t use Ecto, or just for learning.

What our validation module includes:

  • Type validation
  • Number validation
  • Length validation for map, list, string
  • String format validation using regex
  • Inclusion, exclusion validations

That’s is quite enough, you can define more if you want. And the module will support a simple API to validate a value

1validate(value::any(), validations::keyword()) :: :ok | {:error, String.t()}

Let’s start.

Type validation

Let’s call our module Checky. Type check is quite straight forward. Elixir support most of type check guard that we need:

 1defmodule Checky do
 2  def validate_type(value, :boolean) when is_boolean(value), do: :ok
 3  def validate_type(value, :integer) when is_integer(value), do: :ok
 4  def validate_type(value, :float) when is_float(value), do: :ok
 5  def validate_type(value, :number) when is_number(value), do: :ok
 6  def validate_type(value, :string) when is_binary(value), do: :ok
 7  def validate_type(value, :binary) when is_binary(value), do: :ok
 8  def validate_type(value, :tuple) when is_tuple(value), do: :ok
 9  def validate_type(value, :array) when is_list(value), do: :ok
10  def validate_type(value, :list) when is_list(value), do: :ok
11  def validate_type(value, :atom) when is_atom(value), do: :ok
12  def validate_type(value, :function) when is_function(value), do: :ok
13  def validate_type(value, :map) when is_map(value), do: :ok
14  # we will add some more validation here
15  def validate_type(_, type), do: {:error, "is not a #{type}"}
16end

Easy, right? Now let’s support checking for struct:

1defmodule Checky do
2
3  ...
4  # from Elixir 1.12 you can do this
5  def validate_type(value, struct_name) when is_struct(value, struct_name), do: :ok
6  # this is for Elixir before 1.12
7  def validate_type(%{__struct__: struct}, struct_name) when struct == struct_name, do: :ok
8  ...
9end
  • Here we check for keyword
 1defmodule Checky do
 2  ...
 3  # empty list is also a empty keyword
 4  def validate_type([] = _check_item, :keyword), do: :ok
 5  # if list item is a tuple of 2 and first element is atom then it is a keyword list
 6  def validate_type(items, :keyword) when is_list(items) do
 7    valid? = Enum.all(item, fn 
 8        {key, _} when is_atom(key) -> true
 9        _ -> false 
10    end)
11    
12    if valid? do
13      :ok
14    else
15      {:error, "is not a keyword"}
16    end
17  end
18  ...
19end
  • Now let support array check {:array, type} which is similar to Ecto.Schema.
 1defmodule Checky do
 2  ...
 3  def validate_type(value, {:array, type}) when is_list(value) do
 4    # We will check type for each value in the list
 5    array(value, &validate_type(&1, type))
 6  end
 7  ...
 8   # loop and validate element in array using `validate_func`
 9  defp array(data, validate_func)
10
11  defp array([], _) do
12    :ok
13  end
14
15  # validate recursively, and return error if any vadation failed
16  defp array([h | t], validate_func) do
17    case validate_func.(h) do
18      :ok ->
19        array(t, validate_func)
20      err ->
21        err
22    end
23  end
24end

Phew! We have done with type validation. You can add more type validation if you want.

Format Validation

This validation is super easy, Regex do that for us:

1defmodule Checky end
2  def validate_format(value, check) when is_binary(value) do
3    if Regex.match?(check, value), do: :ok, else: {:error, "does not match format"}
4  end
5
6  def validate_format(_value, _check) do
7    {:error, "format check only support string"}
8  end
9end

Inclusion and exclusion validation

These are trivial checks too. Just make sure it is implement Enumerable protocol.

 1defmodule Checky do
 2  def validate_inclusion(value, enum) do
 3    if Enumerable.impl_for(enum) do
 4      if Enum.member?(enum, value) do
 5        :ok
 6      else
 7        {:error, "not be in the inclusion list"}
 8      end
 9    else
10      {:error, "given condition does not implement protocol Enumerable"}
11    end
12  end
13
14  @doc """
15  Check if value is **not** included in the given enumerable. Similar to `validate_inclusion/2`
16  """
17  def validate_exclusion(value, enum) do
18    if Enumerable.impl_for(enum) do
19      if Enum.member?(enum, value) do
20        {:error, "must not be in the exclusion list"}
21      else
22        :ok
23      end
24    else
25      {:error, "given condition does not implement protocol Enumerable"}
26    end
27  end
28end

Number validation

This is one of the most complicated part of our module. It’s not difficult, it’s just long. We will support following checks:

  • equal_to
  • greater_than_or_equal_to | min
  • greater_than
  • less_than
  • less_than_or_equal_to | max

And it should support multiple check like this:

1 validate_number(x, [min: 10, max: 20])

First we code validation function for single condition like this

1  def validate_number(number, {:equal_to, check_value}) do
2    if number == check_value do
3      :ok
4    else
5      {:error, "must be equal to #{check_value}"}
6    end
7  end

As I said, it’s so simple. You can fill the remaining check right? Or you can check the final code at the end of the post. After implementing all validation fucntion for number, it’s time to support multiple condtion check.

 1  @spec validate_number(integer() | float(), keyword()) :: :ok | error
 2  def validate_number(value, checks) when is_list(checks) do
 3    if is_number(value) do
 4      checks
 5      |> Enum.reduce(:ok, fn
 6        check, :ok ->
 7          validate_number(value, check)
 8
 9        _, error ->
10          error
11      end)
12    else
13      {:error, "must be a number"}
14    end
15  end

Length validation

Length is just a number, so we can reuse number validation. We just have to check if given value is one of support types: list, map, string, and tuple

We will implement get_length/1 function to get data length first.

1  @spec get_length(any) :: pos_integer() | {:error, :wrong_type}
2  defp get_length(param) when is_list(param), do: length(param)
3  defp get_length(param) when is_binary(param), do: String.length(param)
4  defp get_length(param) when is_map(param), do: param |> Map.keys() |> get_length()
5  defp get_length(param) when is_tuple(param), do: tuple_size(param)
6  defp get_length(_param), do: {:error, :wrong_type}

Then we do number validation on the length value

 1  @spec validate_length(support_length_types, keyword()) :: :ok | error
 2  def validate_length(value, checks) do
 3    with length when is_integer(length) <- get_length(value),
 4         # validation length number
 5         :ok <- validate_number(length, checks) do
 6      :ok
 7    else
 8      {:error, :wrong_type} ->
 9        {:error, "length check supports only lists, binaries, maps and tuples"}
10
11      {:error, msg} ->
12        # we prepend length to message return by validation number to get full message
13        # like: "length must be equal to x"
14        {:error, "length #{msg}"}
15    end
16  end

Combine all validation

Most of time you want to use multiple valitions on the data. So we will add a function that do multiple validation

We define a simple structure for validation first. This is our validate function spec

1 @spec validate(any(), keyword()) :: :ok | {:error, messages}

Then we can use it like this:

1Checky.validate(value, type: :string, format: ~r/\d\d.+/, length: [min: 8, max: 20])

Validations is a keyword list with short name for validation:

  • :type -> validate_type
  • :format -> validate_format
  • :in -> validate_inclusion
  • :not_in -> validate_exclusion
  • :number -> validate_number
  • :length -> validate_length

Define mapping function:

1  defp get_validator(:type), do: &validate_type/2
2  defp get_validator(:format), do: &validate_format/2
3  defp get_validator(:number), do: &validate_number/2
4  defp get_validator(:length), do: &validate_length/2
5  defp get_validator(:in), do: &validate_inclusion/2
6  defp get_validator(:not_in), do: &validate_exclusion/2
7  defp get_validator(name), do: {:error, "validate_#{name} is not support"}
8

Go checking validations one by one

 1  def validate(value, validators) do
 2    do_validate(value, validators, :ok)
 3  end
 4
 5  defp do_validate(_, [], acc), do: acc
 6
 7  # check validations one by one
 8  defp do_validate(value, [h | t] = _validators, acc) do
 9    case do_validate(value, h) do
10      :ok -> do_validate(value, t, acc)
11      error -> error
12    end
13  end
14
15  # validate single validation
16  defp do_validate(value, {validator, opts}) do
17    case get_validator(validator) do
18      {:error, _} = err -> err
19      validate_func -> validate_func.(value, opts)
20    end
21  end

Conclusion

Writing a validation module is not so hard. Now you can add more validations to fit your need. As I promised, this is the full source of the module with custom validation fucntion. https://github.com/bluzky/valdi/blob/main/lib/valdi.ex

Thank you for reading to the end of this post. Please leave me a comment.