Current section

Files

Jump to
valdi lib valdi.ex
Raw

lib/valdi.ex

defmodule Valdi do
@moduledoc """
A comprehensive Elixir data validation library that provides flexible and composable validation functions.
## Features
- **Type validation** - validate data types including numbers, strings, lists, maps, structs, and Decimal types
- **Constraint validation** - validate ranges, lengths, formats, and inclusion/exclusion
- **Flattened validators** - use convenient aliases like `min`, `max`, `min_length` without nesting
- **Pattern matching** - efficient validation dispatch using Elixir's pattern matching
- **Composable** - combine multiple validations in a single call
- **Backward compatible** - works with existing validation patterns
- **Conditional type checking** - skip type validation when not needed for better performance
## Quick Examples
### Basic validation
```elixir
# Type validation
Valdi.validate("hello", type: :string)
#=> :ok
# Constraint validation without type checking
Valdi.validate("hello", min_length: 3, max_length: 10)
#=> :ok
# Combined validations
Valdi.validate(15, type: :integer, min: 10, max: 20, greater_than: 5)
#=> :ok
```
### Flattened validators (new!)
```elixir
# Instead of nested syntax
Valdi.validate("test", type: :string, length: [min: 3, max: 10])
# Use flattened syntax
Valdi.validate("test", type: :string, min_length: 3, max_length: 10)
# Mix both styles
Valdi.validate(15, min: 10, number: [max: 20])
```
### List and map validation
```elixir
# Validate each item in a list
Valdi.validate_list([1, 2, 3], type: :integer, min: 0)
# Validate map with schema
schema = %{
name: [type: :string, required: true, min_length: 2],
age: [type: :integer, min: 0, max: 150],
email: [type: :string, format: ~r/.+@.+/]
}
Valdi.validate_map(%{name: "John", age: 30}, schema)
```
## Available Validators
### Core validators
- `type` - validate data type
- `required` - ensure value is not nil
- `format`/`pattern` - regex pattern matching
- `in`/`enum` - value inclusion validation
- `not_in` - value exclusion validation
- `func` - custom validation function
### Numeric validators
- `number` - numeric constraints (nested)
- `min` - minimum value (≥)
- `max` - maximum value (≤)
- `greater_than` - strictly greater than (>)
- `less_than` - strictly less than (<)
### Length validators
- `length` - length constraints (nested)
- `min_length` - minimum length
- `max_length` - maximum length
- `min_items` - minimum array items (alias for min_length)
- `max_items` - maximum array items (alias for max_length)
### Other validators
- `each` - validate each item in arrays
- `decimal` - decimal validation (**deprecated**, use `number` instead)
## Options
- `ignore_unknown: true` - skip unknown validators instead of returning errors
## Individual Validation Functions
Each validator can also be used independently:
```elixir
Valdi.validate_type("hello", :string)
Valdi.validate_number(15, min: 10, max: 20)
Valdi.validate_length("hello", min: 3, max: 10)
Valdi.validate_inclusion("red", ["red", "green", "blue"])
```
"""
require Decimal
@type error :: {:error, String.t()}
@doc """
Validate value against list of validations.
```elixir
iex> Valdi.validate("email@g.c", type: :string, format: ~r/.+@.+\.[a-z]{2,10}/)
{:error, "does not match format"}
```
**All supported validations**:
- `type`: validate datatype
- `format`|`pattern`: check if binary value matched given regex
- `number`: validate number value (supports both regular numbers and Decimal types)
- `length`: validate length of supported types. See `validate_length/2` for more details.
- `in`|`enum`: validate inclusion
- `not_in`: validate exclusion
- `min`: validate minimum value for numbers
- `max`: validate maximum value for numbers
- `greater_than`: validate value is greater than specified number
- `less_than`: validate value is less than specified number
- `min_length`: validate minimum length for strings, lists, maps, tuples
- `max_length`: validate maximum length for strings, lists, maps, tuples
- `min_items`: validate minimum number of items in arrays (alias for min_length)
- `max_items`: validate maximum number of items in arrays (alias for max_length)
- `func`: custom validation function follows spec `func(any()):: :ok | {:error, message::String.t()}`
- `each`: validate each item in list with given validator. Supports all above validator
- `decimal`: validate decimal values (**deprecated**, use `number` instead)
**Options**:
- `ignore_unknown`: when `true`, unknown validators are ignored instead of returning an error (default: `false`)
```elixir
iex> Valdi.validate("test", [type: :string, unknown_validator: :value], ignore_unknown: true)
:ok
iex> Valdi.validate("test", [type: :string, unknown_validator: :value], ignore_unknown: false)
{:error, "validate_unknown_validator is not supported"}
iex> Valdi.validate(15, type: :integer, min: 10, max: 20)
:ok
iex> Valdi.validate(15, type: :integer, greater_than: 10, less_than: 20)
:ok
iex> Valdi.validate("hello", type: :string, min_length: 3, max_length: 10)
:ok
iex> Valdi.validate([1, 2, 3], type: :list, min_items: 2, max_items: 5)
:ok
iex> Valdi.validate("hello", min_length: 3)
:ok
```
"""
@spec validate(any(), keyword()) :: :ok | error
def validate(value, validators, opts \\ []) do
validators = prepare_validator(validators)
do_validate(value, validators, :ok, opts)
end
@doc """
Validate list value against validator and return error if any item is not valid.
In case of error `{:error, errors}`, `errors` is list of error detail for all error item includes `[index, message]`
```elixir
iex> Valdi.validate_list([1,2,3], type: :integer, number: [min: 2])
{:error, [[0, "must be greater than or equal to 2"]]}
```
"""
@spec validate_list(list(), keyword()) :: :ok | {:error, list()}
def validate_list(items, validators, opts \\ []) do
validators = prepare_validator(validators)
items
|> Enum.with_index()
|> Enum.reduce({:ok, []}, fn {value, index}, {status, acc} ->
case do_validate(value, validators, :ok, opts) do
:ok -> {status, acc}
{:error, message} -> {:error, [[index, message] | acc]}
end
end)
|> case do
{:ok, _} -> :ok
{:error, errors} -> {:error, Enum.reverse(errors)}
end
end
@doc """
Validate map value with given map specification.
Validation spec is a map
`validate_map` use the key from validation to extract value from input data map and then validate value against the validators for that key.
In case of error, the error detail is a map of error for each key.
```elixir
iex> validation_spec = %{
...> email: [type: :string, required: true],
...> password: [type: :string, length: [min: 8]],
...> age: [type: :integer, number: [min: 16, max: 60]]
...> }
iex> Valdi.validate_map(%{name: "dzung", password: "123456", email: "ddd@example.com", age: 28}, validation_spec)
{:error, %{password: "length must be greater than or equal to 8"}}
```
"""
@spec validate_map(map(), map()) :: :ok | {:error, map()}
def validate_map(data, validations_spec, opts \\ []) do
validations_spec
|> Enum.reduce({:ok, []}, fn {key, validators}, {status, acc} ->
validators = prepare_validator(validators)
case do_validate(Map.get(data, key), validators, :ok, opts) do
:ok -> {status, acc}
{:error, message} -> {:error, [{key, message} | acc]}
end
end)
|> case do
{:ok, _} -> :ok
{:error, messages} -> {:error, Enum.into(messages, %{})}
end
end
# prioritize checking
# `required` -> `type` -> others
defp prepare_validator(validators) do
{required, validators} = Keyword.pop(validators, :required, false)
{type, validators} = Keyword.pop(validators, :type)
validators = if type, do: [{:type, type} | validators], else: validators
[{:required, required} | validators]
end
defp do_validate(_, [], acc, _), do: acc
defp do_validate(value, [h | t] = _validators, acc, opts) do
case do_validate(value, h, opts) do
:ok -> do_validate(value, t, acc, opts)
error -> error
end
end
# validate required need to check nil
defp do_validate(value, {:required, validator_opts}, _opts), do: validate_required(value, validator_opts)
# other validation is skipped if value is nil
defp do_validate(nil, _, _opts), do: :ok
# pattern match on each validator type
defp do_validate(value, {:type, validator_opts}, _opts), do: validate_type(value, validator_opts)
defp do_validate(value, {:format, validator_opts}, _opts), do: validate_format(value, validator_opts)
defp do_validate(value, {:pattern, validator_opts}, _opts), do: validate_format(value, validator_opts)
defp do_validate(value, {:number, validator_opts}, _opts), do: validate_number(value, validator_opts)
defp do_validate(value, {:length, validator_opts}, _opts), do: validate_length(value, validator_opts)
defp do_validate(value, {:in, validator_opts}, _opts), do: validate_inclusion(value, validator_opts)
defp do_validate(value, {:enum, validator_opts}, _opts), do: validate_inclusion(value, validator_opts)
defp do_validate(value, {:not_in, validator_opts}, _opts), do: validate_exclusion(value, validator_opts)
defp do_validate(value, {:min, min_value}, _opts), do: validate_number(value, [min: min_value])
defp do_validate(value, {:max, max_value}, _opts), do: validate_number(value, [max: max_value])
defp do_validate(value, {:greater_than, gt_value}, _opts), do: validate_number(value, [greater_than: gt_value])
defp do_validate(value, {:less_than, lt_value}, _opts), do: validate_number(value, [less_than: lt_value])
defp do_validate(value, {:min_length, min_len}, _opts), do: validate_length(value, [min: min_len])
defp do_validate(value, {:max_length, max_len}, _opts), do: validate_length(value, [max: max_len])
defp do_validate(value, {:min_items, min_items}, _opts), do: validate_length(value, [min: min_items])
defp do_validate(value, {:max_items, max_items}, _opts), do: validate_length(value, [max: max_items])
defp do_validate(value, {:each, validator_opts}, opts), do: validate_each_item(value, validator_opts, opts)
defp do_validate(value, {:decimal, validator_opts}, _opts), do: validate_decimal(value, validator_opts)
defp do_validate(value, {:func, func}, _opts), do: func.(value)
# catch-all for unknown validators
defp do_validate(_value, {validator, _validator_opts}, opts) do
if Keyword.get(opts, :ignore_unknown, false) do
:ok
else
{:error, "validate_#{validator} is not supported"}
end
end
@doc """
Validate embedded types using a module's `validate/2` callback.
Accepts an `{:embed, module, params}` tuple where `module` must implement `validate/2`.
Also supports `{:array, {:embed, module, params}}` to validate a list of embedded values.
```elixir
# Single embedded struct
Valdi.validate_embed(%MyStruct{}, {:embed, MyStruct, [field: [type: :string]]})
# Array of embedded structs
Valdi.validate_embed([%MyStruct{}], {:array, {:embed, MyStruct, [field: [type: :string]]}})
```
"""
def validate_embed(value, embed_type)
def validate_embed(value, {:embed, mod, params}) when is_map(value) do
mod.validate(value, params)
end
def validate_embed(value, {:array, {:embed, _, _} = type}) when is_list(value) do
array(value, &validate_embed(&1, type), true)
end
def validate_embed(_, _) do
{:error, "is invalid"}
end
@doc """
Validate data types.
```elixir
iex> Valdi.validate_type("a string", :string)
:ok
iex> Valdi.validate_type("a string", :number)
{:error, "is not a number"}
```
Support built-in types:
- `boolean`
- `integer`
- `float`
- `number` (integer or float)
- `string` | `binary`
- `tuple`
- `map`
- `array`
- `atom`
- `function`
- `keyword`
- `date`
- `datetime`
- `naive_datetime`
- `time`
It can also check extend types
- `struct` Ex: `User`
- `{:array, type}` : array of type
- `%{key: validators}` : inline map validation spec (delegates to `validate_map/3`)
"""
def validate_type(value, :boolean) when is_boolean(value), do: :ok
def validate_type(value, :integer) when is_integer(value), do: :ok
def validate_type(value, :float) when is_float(value), do: :ok
def validate_type(value, :number) when is_number(value), do: :ok
def validate_type(value, :string) when is_binary(value), do: :ok
def validate_type(value, :binary) when is_binary(value), do: :ok
def validate_type(value, :tuple) when is_tuple(value), do: :ok
def validate_type(value, :array) when is_list(value), do: :ok
def validate_type(value, :list) when is_list(value), do: :ok
def validate_type(value, :atom) when is_atom(value), do: :ok
def validate_type(value, :function) when is_function(value), do: :ok
def validate_type(value, :map) when is_map(value), do: :ok
def validate_type(%Decimal{} = _value, :decimal), do: :ok
def validate_type(value, :date), do: validate_type(value, Date)
def validate_type(value, :time), do: validate_type(value, Time)
def validate_type(value, :datetime), do: validate_type(value, DateTime)
def validate_type(value, :utc_datetime), do: validate_type(value, DateTime)
def validate_type(value, :naive_datetime), do: validate_type(value, NaiveDateTime)
def validate_type(_value, :any), do: :ok
def validate_type(value, {:array, type}) when is_list(value) do
case array(value, &validate_type(&1, type)) do
:ok -> :ok
_ -> {:error, "is invalid"}
end
end
def validate_type(value, %{} = map), do: validate_map(value, map)
def validate_type([] = _check_item, :keyword), do: :ok
def validate_type([{atom, _} | _] = _check_item, :keyword) when is_atom(atom), do: :ok
def validate_type(%{__struct__: struct}, struct_name) when struct == struct_name, do: :ok
def validate_type(_, type) when is_tuple(type), do: {:error, "is not an array"}
def validate_type(_, type), do: {:error, "is not a #{type}"}
# loop and validate element in array using `validate_func`
defp array(data, validate_func, return_data \\ false, acc \\ [])
defp array([], _, return_data, acc) do
if return_data do
{:ok, Enum.reverse(acc)}
else
:ok
end
end
defp array([h | t], validate_func, return_data, acc) do
case validate_func.(h) do
:ok ->
array(t, validate_func, return_data, [h | acc])
{:ok, data} ->
array(t, validate_func, return_data, [data | acc])
{:error, _} = err ->
err
end
end
@doc """
Validate value if value is not nil. This function can receive a function to dynamicall calculate required or not.
```elixir
iex> Valdi.validate_required(nil, true)
{:error, "is required"}
iex> Valdi.validate_required(1, true)
:ok
iex> Valdi.validate_required(nil, false)
:ok
iex> Valdi.validate_required(nil, fn -> 2 == 2 end)
{:error, "is required"}
```
"""
def validate_required(value, func) when is_function(func, 0),
do: validate_required(value, func.())
def validate_required(nil, true), do: {:error, "is required"}
def validate_required(_, _), do: :ok
@doc """
Validate number value
```elixir
iex> Valdi.validate_number(12, min: 10, max: 12)
:ok
iex> Valdi.validate_number(12, min: 15)
{:error, "must be greater than or equal to 15"}
iex> Valdi.validate_number(Decimal.new("12.5"), min: Decimal.new("10.0"))
:ok
```
Support conditions
- `equal_to`
- `greater_than_or_equal_to` | `min`
- `greater_than`
- `less_than`
- `less_than_or_equal_to` | `max`
Works with both regular numbers and Decimal types.
"""
@spec validate_number(integer() | float() | Decimal.t(), keyword()) :: :ok | error
def validate_number(value, checks) when is_list(checks) and is_number(value) do
Enum.reduce(checks, :ok, fn
check, :ok -> validate_number(value, check)
_, error -> error
end)
end
def validate_number(value, checks) when is_list(checks) and not is_number(value) do
if Decimal.is_decimal(value) do
Enum.reduce(checks, :ok, fn
check, :ok -> validate_number(value, check)
_, error -> error
end)
else
{:error, "must be a number"}
end
end
# Number comparisons
def validate_number(number, {:equal_to, check_value}) when is_number(number) and is_number(check_value) do
if number == check_value, do: :ok, else: {:error, "must be equal to #{check_value}"}
end
def validate_number(number, {:greater_than, check_value}) when is_number(number) and is_number(check_value) do
if number > check_value, do: :ok, else: {:error, "must be greater than #{check_value}"}
end
def validate_number(number, {:greater_than_or_equal_to, check_value}) when is_number(number) and is_number(check_value) do
if number >= check_value, do: :ok, else: {:error, "must be greater than or equal to #{check_value}"}
end
def validate_number(number, {:min, check_value}) when is_number(number) and is_number(check_value) do
validate_number(number, {:greater_than_or_equal_to, check_value})
end
def validate_number(number, {:less_than, check_value}) when is_number(number) and is_number(check_value) do
if number < check_value, do: :ok, else: {:error, "must be less than #{check_value}"}
end
def validate_number(number, {:less_than_or_equal_to, check_value}) when is_number(number) and is_number(check_value) do
if number <= check_value, do: :ok, else: {:error, "must be less than or equal to #{check_value}"}
end
def validate_number(number, {:max, check_value}) when is_number(number) and is_number(check_value) do
validate_number(number, {:less_than_or_equal_to, check_value})
end
# Decimal comparisons
def validate_number(decimal, {:equal_to, %Decimal{} = check_value}) do
if Decimal.eq?(decimal, check_value), do: :ok, else: {:error, "must be equal to #{check_value}"}
end
def validate_number(decimal, {:greater_than, %Decimal{} = check_value}) do
if Decimal.gt?(decimal, check_value), do: :ok, else: {:error, "must be greater than #{check_value}"}
end
def validate_number(decimal, {:greater_than_or_equal_to, %Decimal{} = check_value}) do
if Decimal.gt?(decimal, check_value) or Decimal.eq?(decimal, check_value) do
:ok
else
{:error, "must be greater than or equal to #{check_value}"}
end
end
def validate_number(decimal, {:min, %Decimal{} = check_value}) do
validate_number(decimal, {:greater_than_or_equal_to, check_value})
end
def validate_number(decimal, {:less_than, %Decimal{} = check_value}) do
if Decimal.lt?(decimal, check_value), do: :ok, else: {:error, "must be less than #{check_value}"}
end
def validate_number(decimal, {:less_than_or_equal_to, %Decimal{} = check_value}) do
if Decimal.lt?(decimal, check_value) or Decimal.eq?(decimal, check_value) do
:ok
else
{:error, "must be less than or equal to #{check_value}"}
end
end
def validate_number(decimal, {:max, %Decimal{} = check_value}) do
validate_number(decimal, {:less_than_or_equal_to, check_value})
end
# Decimal value with a non-Decimal check value — type mismatch
def validate_number(%Decimal{} = _value, {check, check_value})
when check in [:equal_to, :greater_than, :greater_than_or_equal_to, :min, :less_than, :less_than_or_equal_to, :max]
and not is_struct(check_value, Decimal) do
{:error, "check value for '#{check}' must be a Decimal when validating a Decimal number"}
end
# Error cases
def validate_number(_number, {check, _check_value}) do
{:error, "unknown check '#{check}'"}
end
@doc """
Validate decimal values.
**Deprecated**: Use `validate_number/2` instead, which now supports both numbers and Decimal types.
```elixir
# Instead of this (deprecated):
Valdi.validate_decimal(Decimal.new("12.5"), min: Decimal.new("10.0"))
# Use this:
Valdi.validate_number(Decimal.new("12.5"), min: Decimal.new("10.0"))
```
"""
@deprecated "Use validate_number/2 instead, which now supports both numbers and Decimal types"
@spec validate_decimal(Decimal.t(), keyword()) :: :ok | error
def validate_decimal(value, checks), do: validate_number(value, checks)
@doc """
Check if length of value match given conditions. Length condions are the same with `validate_number/2`
```elixir
iex> Valdi.validate_length([1], min: 2)
{:error, "length must be greater than or equal to 2"}
iex> Valdi.validate_length("hello", equal_to: 5)
:ok
```
**Supported types**
- `list`
- `map`
- `tuple`
- `keyword`
- `string`
"""
@type support_length_types :: String.t() | map() | list() | tuple()
@spec validate_length(support_length_types, keyword()) :: :ok | error
def validate_length(value, checks) do
with length when is_integer(length) <- get_length(value),
:ok <- validate_number(length, checks) do
:ok
else
{:error, :wrong_type} ->
{:error, "length check supports only lists, binaries, maps and tuples"}
{:error, msg} ->
{:error, "length #{msg}"}
end
end
@spec get_length(any) :: pos_integer() | {:error, :wrong_type}
defp get_length(param) when is_list(param), do: length(param)
defp get_length(param) when is_binary(param), do: String.length(param)
defp get_length(param) when is_map(param), do: param |> Map.keys() |> get_length()
defp get_length(param) when is_tuple(param), do: tuple_size(param)
defp get_length(_param), do: {:error, :wrong_type}
@doc """
Checks whether a string match the given regex pattern.
```elixir
iex> Valdi.validate_format("year: 2001", ~r/year:\\s\\d{4}/)
:ok
iex> Valdi.validate_format("hello", ~r/\d+/)
{:error, "does not match format"}
iex> Valdi.validate_format("hello", "h.*o")
:ok
```
"""
@spec validate_format(String.t(), Regex.t() | String.t()) ::
:ok | error
def validate_format(value, check) when is_binary(value) and is_binary(check) do
case Regex.compile(check) do
{:ok, regex} -> validate_format(value, regex)
{:error, _} -> {:error, "invalid regex pattern"}
end
end
def validate_format(value, check) when is_binary(value) do
if Regex.match?(check, value), do: :ok, else: {:error, "does not match format"}
end
def validate_format(_value, _check) do
{:error, "format check only support string"}
end
@doc """
Check if value is included in the given enumerable.
```elixir
iex> Valdi.validate_inclusion(1, [1, 2])
:ok
iex> Valdi.validate_inclusion(1, {1, 2})
{:error, "given condition does not implement protocol Enumerable"}
iex> Valdi.validate_inclusion(1, %{a: 1, b: 2})
{:error, "must be in the inclusion list"}
iex> Valdi.validate_inclusion({:a, 1}, %{a: 1, b: 2})
:ok
```
"""
def validate_inclusion(value, enum) do
if Enumerable.impl_for(enum) do
if Enum.member?(enum, value) do
:ok
else
{:error, "must be in the inclusion list"}
end
else
{:error, "given condition does not implement protocol Enumerable"}
end
end
@doc """
Check if value is **not** included in the given enumerable. Similar to `validate_inclusion/2`
"""
def validate_exclusion(value, enum) do
if Enumerable.impl_for(enum) do
if Enum.member?(enum, value) do
{:error, "must not be in the exclusion list"}
else
:ok
end
else
{:error, "given condition does not implement protocol Enumerable"}
end
end
@doc """
Apply validation for each array item
"""
def validate_each_item(list, validations, opts \\ []) do
if is_list(list) do
validate_list(list, validations, opts)
else
{:error, "each validation only support array type"}
end
end
end