This guide covers the Sinter validation system, from basic usage through batch processing and error handling.
Validation Pipeline
Every call to Sinter.Validator.validate/3 runs a 5-step pipeline:
- Input Validation -- Ensures the input is a valid map.
- Required Field Check -- Verifies all required fields are present.
- Field Validation -- Validates each field against its type and constraints.
- Strict Mode Check -- Rejects unknown fields when strict mode is enabled.
- Post Validation -- Runs custom cross-field validation if configured.
The pipeline short-circuits on the first step that produces errors, so later steps never run against invalid data.
Basic Validation
Use Sinter.Validator.validate/3 to validate a map against a schema. It
returns {:ok, validated_data} on success or {:error, errors} on failure.
schema = Sinter.Schema.define([
{:name, :string, [required: true, min_length: 2]},
{:age, :integer, [optional: true, gt: 0]}
])
# Successful validation
{:ok, validated} = Sinter.Validator.validate(schema, %{name: "Alice", age: 30})
# => {:ok, %{"name" => "Alice", "age" => 30}}
# Validation failure -- missing required field
{:error, errors} = Sinter.Validator.validate(schema, %{age: 30})
# errors contains a %Sinter.Error{path: ["name"], code: :required, ...}Keys in the input map can be atoms or strings. Sinter normalizes all keys to strings internally.
Bang Variant
Sinter.Validator.validate!/3 returns the validated data directly on success
and raises Sinter.ValidationError on failure.
# Returns the validated map
validated = Sinter.Validator.validate!(schema, %{name: "Alice", age: 30})
# Raises Sinter.ValidationError
try do
Sinter.Validator.validate!(schema, %{age: -1})
rescue
e in Sinter.ValidationError ->
IO.puts(e.message)
# => "Validation failed with 2 errors:\nname: field is required\nage: must be greater than 0"
# Access structured errors programmatically
Enum.each(e.errors, fn error ->
IO.inspect({error.path, error.code})
end)
endType Coercion
Pass coerce: true to automatically convert compatible types before
validation. Coercion is safe and predictable -- it never raises and only
performs well-defined conversions.
schema = Sinter.Schema.define([
{:count, :integer, [required: true]},
{:ratio, :float, [required: true]},
{:active, :boolean, [required: true]},
{:label, :string, [required: true]}
])
{:ok, validated} = Sinter.Validator.validate(schema, %{
count: "42",
ratio: "3.14",
active: "true",
label: :hello
}, coerce: true)
# validated => %{"count" => 42, "ratio" => 3.14, "active" => true, "label" => "hello"}Supported Coercions
| Target Type | Accepted Source Types |
|---|---|
:string | atom, integer, float, boolean |
:integer | string (parseable, e.g. "42") |
:float | string (parseable), integer |
:boolean | "true" / "false" strings |
:atom | string (must be an existing atom) |
:date | Date struct (converted to ISO 8601 string) |
:datetime | DateTime / NaiveDateTime (to ISO 8601 string) |
When coercion fails, you receive a Sinter.Error with code: :coercion:
{:error, [error]} = Sinter.Validator.validate(
Sinter.Schema.define([{:n, :integer, []}]),
%{n: "not_a_number"},
coerce: true
)
error.code # => :coercion
error.message # => "cannot coerce 'not_a_number' to integer"Strict Mode
By default, Sinter ignores fields in the input that are not defined in the schema. Enable strict mode to reject unknown fields.
Strict mode can be set at schema level or per-call:
# Schema-level strict mode
schema = Sinter.Schema.define(
[{:name, :string, [required: true]}],
strict: true
)
{:error, [error]} = Sinter.Validator.validate(schema, %{name: "Alice", extra: "field"})
error.code # => :strict
error.message # => "unexpected fields: [\"extra\"]"
# Per-call strict mode (overrides the schema setting)
schema = Sinter.Schema.define([{:name, :string, [required: true]}])
{:error, _} = Sinter.Validator.validate(schema, %{name: "Alice", extra: 1}, strict: true)Pre/Post Validation Hooks
Pre-Validation
The pre_validate function transforms raw input data before the validation
pipeline runs. Use it to normalize, rename, or reshape incoming data.
schema = Sinter.Schema.define(
[
{:email, :string, [required: true]},
{:name, :string, [required: true]}
],
pre_validate: fn data ->
data
|> Map.update("email", nil, &String.downcase/1)
|> Map.update("name", nil, &String.trim/1)
end
)
{:ok, validated} = Sinter.Validator.validate(schema, %{
email: "Alice@Example.COM",
name: " Alice "
})
# validated["email"] => "alice@example.com"
# validated["name"] => "Alice"Post-Validation
The post_validate function runs after all fields pass validation. Use it for
cross-field constraints that cannot be expressed per-field.
The function receives the validated data map and must return
{:ok, data} or {:error, reason}.
schema = Sinter.Schema.define(
[
{:password, :string, [required: true, min_length: 8]},
{:password_confirmation, :string, [required: true]}
],
post_validate: fn data ->
if data["password"] == data["password_confirmation"] do
{:ok, data}
else
{:error, "password and confirmation do not match"}
end
end
)
{:error, [error]} = Sinter.Validator.validate(schema, %{
password: "secret123",
password_confirmation: "secret456"
})
error.code # => :post_validation
error.message # => "password and confirmation do not match"You can also return a list of Sinter.Error structs for multiple post-validation failures:
post_validate: fn data ->
errors = []
errors =
if data["start_date"] > data["end_date"],
do: [Sinter.Error.new([:end_date], :range, "must be after start_date") | errors],
else: errors
case errors do
[] -> {:ok, data}
errs -> {:error, errs}
end
endCustom Field Validators
The :validate field option accepts a function or list of functions for
per-field custom validation. Each function receives the field value and must
return :ok, {:ok, value}, {:error, message}, or {:error, %Sinter.Error{}}.
schema = Sinter.Schema.define([
{:email, :string, [
required: true,
validate: fn value ->
if String.contains?(value, "@"),
do: :ok,
else: {:error, "must be a valid email address"}
end
]},
{:score, :integer, [
required: true,
validate: [
fn value -> if value >= 0, do: :ok, else: {:error, "must be non-negative"} end,
fn value -> if rem(value, 5) == 0, do: :ok, else: {:error, "must be a multiple of 5"} end
]
]}
])
{:error, errors} = Sinter.Validator.validate(schema, %{email: "invalid", score: 7})
# Two errors: one for email, one for score (pipeline stops at first failing validator per field)When multiple validators are provided as a list, they run in order and short-circuit on the first failure.
Batch Validation
Sinter.Validator.validate_many/3 validates a list of maps against the same
schema. It returns {:ok, validated_list} when all items pass, or
{:error, errors_by_index} with a map from index to errors.
schema = Sinter.Schema.define([
{:name, :string, [required: true]},
{:age, :integer, [required: true, gt: 0]}
])
data = [
%{name: "Alice", age: 30},
%{name: "Bob", age: 25},
%{name: "Charlie", age: 35}
]
{:ok, validated} = Sinter.Validator.validate_many(schema, data)
# validated is a list of three validated maps
# When some items fail:
bad_data = [
%{name: "Alice", age: 30},
%{name: "", age: -1},
%{age: 20}
]
{:error, errors_by_index} = Sinter.Validator.validate_many(schema, bad_data)
# errors_by_index is a map: %{1 => [...], 2 => [...]}
# Index 0 (Alice) passed, so it does not appear in the error mapError paths in batch validation include the item index, so
error.path might look like [1, "name"] for a failure on the second
item's name field.
Stream Validation
Sinter.Validator.validate_stream/3 wraps a stream (or any enumerable) and
validates each element lazily. This is useful for processing large datasets
without loading everything into memory.
schema = Sinter.Schema.define([{:id, :integer, [required: true]}])
results =
1..1_000_000
|> Stream.map(&%{id: &1})
|> Sinter.Validator.validate_stream(schema)
|> Stream.filter(&match?({:ok, _}, &1))
|> Stream.map(fn {:ok, data} -> data end)
|> Enum.take(5)
# => [%{"id" => 1}, %{"id" => 2}, %{"id" => 3}, %{"id" => 4}, %{"id" => 5}]Note the argument order: the stream is the second argument (after the schema),
matching the validate/3 convention.
Each element in the resulting stream is either {:ok, validated} or
{:error, errors}, so you can partition successes and failures downstream.
Error Handling
All validation errors are represented as Sinter.Error structs with four
fields:
| Field | Type | Description |
|---|---|---|
path | [atom | String.t | integer] | Path to the offending field |
code | atom | Machine-readable error code |
message | String.t | Human-readable description |
context | map | nil | Optional additional context |
Formatting Errors
error = Sinter.Error.new([:user, :email], :format, "invalid email format")
Sinter.Error.format(error)
# => "user.email: invalid email format"
Sinter.Error.format(error, include_path: false)
# => "invalid email format"
Sinter.Error.format(error, path_separator: "/")
# => "user/email: invalid email format"Grouping Errors
errors = [
Sinter.Error.new([:name], :required, "field is required"),
Sinter.Error.new([:name], :min_length, "too short"),
Sinter.Error.new([:email], :format, "invalid format"),
Sinter.Error.new([:age], :required, "field is required")
]
# Group by field path
Sinter.Error.group_by_path(errors)
# => %{
# [:name] => [%Error{code: :required, ...}, %Error{code: :min_length, ...}],
# [:email] => [%Error{code: :format, ...}],
# [:age] => [%Error{code: :required, ...}]
# }
# Group by error code
Sinter.Error.group_by_code(errors)
# => %{
# required: [%Error{path: [:name], ...}, %Error{path: [:age], ...}],
# min_length: [%Error{path: [:name], ...}],
# format: [%Error{path: [:email], ...}]
# }Serializing Errors
Error.to_map/1 converts an error to a plain map suitable for JSON
serialization:
error = Sinter.Error.new([:user, :email], :format, "invalid email format")
Sinter.Error.to_map(error)
# => %{
# "path" => ["user", "email"],
# "code" => "format",
# "message" => "invalid email format"
# }For a list of errors, use Sinter.Error.to_maps/1.
Convenience Helpers
The top-level Sinter module provides shorthand functions that create
temporary schemas internally, useful for one-off validations.
Sinter.validate_type/3
Validates a single value against a type specification.
{:ok, 42} = Sinter.validate_type(:integer, "42", coerce: true)
{:ok, ["a", "b"]} = Sinter.validate_type({:array, :string}, ["a", "b"])
{:error, [error]} = Sinter.validate_type(:string, 123)
error.code # => :typeSinter.validate_value/4
Validates a named value with constraints. The field name appears in error paths.
{:ok, "test@example.com"} = Sinter.validate_value(
:email, :string, "test@example.com",
constraints: [format: ~r/@/]
)
{:ok, 95} = Sinter.validate_value(
:score, :integer, "95",
coerce: true, constraints: [gteq: 0, lteq: 100]
)Sinter.validate_many/2
Validates multiple values against different type specifications in a single call.
{:ok, results} = Sinter.validate_many([
{:string, "hello"},
{:integer, 42},
{:email, :string, "user@example.com", [format: ~r/@/]}
])
# results => ["hello", 42, "user@example.com"]Sinter.validator_for/2
Creates a reusable validation function for repeated single-value checks.
email_validator = Sinter.validator_for(:string, constraints: [format: ~r/@/])
{:ok, "a@b.com"} = email_validator.("a@b.com")
{:error, _} = email_validator.("invalid")Sinter.batch_validator_for/2
Creates a reusable validation function backed by a pre-built schema. Avoids re-creating the schema on every call.
validate_user = Sinter.batch_validator_for([
{:name, :string},
{:age, :integer}
])
{:ok, validated} = validate_user.(%{name: "Alice", age: 30})
{:error, errors} = validate_user.(%{name: 123})