This guide covers how to define, configure, and query schemas in Sinter. Schemas are the foundation of Sinter's validation system -- they describe the structure, types, and constraints of your data.

Runtime Schemas

Use Sinter.Schema.define/2 to create schemas at runtime. Each field is specified as a {name, type, opts} tuple.

schema = Sinter.Schema.define([
  {:name, :string, [required: true, min_length: 2]},
  {:age, :integer, [optional: true, gt: 0]},
  {:active, :boolean, [optional: true, default: true]}
])

The second argument accepts schema-level configuration options:

schema = Sinter.Schema.define(
  [
    {:title, :string, [required: true, min_length: 3, max_length: 100]},
    {:price, :float, [required: true, gt: 0.0]},
    {:category, :string, [required: true, choices: ["electronics", "books"]]}
  ],
  title: "Product Schema",
  description: "Validates product data",
  strict: true
)

Runtime schemas are first-class data structures (Sinter.Schema.t()) that can be passed around, stored, and composed dynamically. This makes them well suited for frameworks that build schemas at runtime.

Compile-Time Schemas

For schemas that are known at compile time, use the use Sinter.Schema macro together with a use_schema block. This produces a module with a schema/0 function that returns a precompiled Sinter.Schema.t().

defmodule UserSchema do
  use Sinter.Schema

  use_schema do
    option :title, "User Schema"
    option :strict, true

    field :name, :string, required: true, min_length: 2
    field :age, :integer, optional: true, gt: 0
    field :active, :boolean, optional: true, default: true
  end
end

# Retrieve the compiled schema
schema = UserSchema.schema()

The field macro accepts the same arguments as a runtime field tuple -- field name, type, opts. The option macro sets schema-level configuration (:title, :description, :strict, etc.).

field and option calls can be interleaved in any order inside the use_schema block.

Supported Types

Primitive Types

TypeElixir Values
:stringBinaries ("hello")
:integerIntegers (42)
:floatFloats (3.14)
:booleantrue / false
:atomAtoms (:ok)
:anyAny value (no type check)
:mapAny map (%{})
:dateISO 8601 date string ("2024-01-15")
:datetimeISO 8601 datetime string ("2024-01-15T10:30:00Z")
:uuidUUID string ("550e8400-e29b-41d4-a716-446655440000")
:nullnil

Composite Types

Array -- a list where every element matches the inner type:

{:tags, {:array, :string}, [required: true]}
{:matrix, {:array, {:array, :integer}}, [optional: true]}

Union -- a value that matches any one of the listed types:

{:id, {:union, [:string, :integer]}, [required: true]}

Tuple -- a fixed-size tuple with positional types:

{:coordinates, {:tuple, [:float, :float]}, [required: true]}
{:rgb, {:tuple, [:integer, :integer, :integer]}, [required: true]}

Typed map -- a map with constrained key and value types:

{:metadata, {:map, :string, :any}, [optional: true]}
{:scores, {:map, :string, :integer}, [required: true]}

Nullable -- allows nil in addition to the inner type:

{:middle_name, {:nullable, :string}, [optional: true]}

Literal -- matches exactly one value:

{:type, {:literal, "text"}, [required: true]}
{:version, {:literal, 2}, [required: true]}

Discriminated union -- selects a variant schema based on a discriminator field. Each variant must be a Sinter.Schema.t():

text_schema = Sinter.Schema.define([
  {:type, {:literal, "text"}, [required: true]},
  {:content, :string, [required: true]}
])

image_schema = Sinter.Schema.define([
  {:type, {:literal, "image"}, [required: true]},
  {:data, :string, [required: true]},
  {:format, :string, [required: true, choices: ["png", "jpeg"]]}
])

{:payload,
 {:discriminated_union,
  [
    discriminator: "type",
    variants: %{
      "text" => text_schema,
      "image" => image_schema
    }
  ]}, [required: true]}

The discriminator can be a string or atom key. Sinter looks up the discriminator value in the input map, finds the matching variant, and validates against that variant's schema. If the discriminator field is missing or its value does not match any variant key, validation fails with a descriptive error.

Each variant must define the discriminator field itself, and that field must use {:literal, value} with a value matching the variant key. Sinter validates this when the schema is defined, so malformed discriminated unions fail fast instead of producing ambiguous runtime behavior.

Field Options

Every field accepts the following options as a keyword list:

OptionDescription
:requiredField must be present. Defaults to true.
:optionalField may be omitted. Equivalent to required: false.
:defaultValue used when the field is absent. Implies optional: true.
:descriptionHuman-readable description (used in generated JSON Schema).
:exampleExample value for documentation purposes.
:aliasAlternate field name accepted on input and used in JSON Schema output.
:validateCustom validator function or list of functions (see below).

You cannot specify both :required and :optional on the same field.

Sinter.Schema.define([
  {:name, :string, [required: true, description: "User's full name", example: "Jane Doe"]},
  {:role, :string, [default: "member", choices: ["admin", "member", "guest"]]},
  {:account_name, :string, [required: true, alias: "accountName"]}
])

Field Aliases

An alias lets you accept input under a different key (e.g., camelCase from JSON) while keeping a canonical snake_case name internally. During validation, the alias key takes precedence if both the alias and canonical name are present.

schema = Sinter.Schema.define([
  {:account_name, :string, [required: true, alias: "accountName"]},
  {:user_id, :string, [required: true, alias: "userId"]}
])

# Input uses alias keys
{:ok, result} = Sinter.Validator.validate(schema, %{"accountName" => "Acme", "userId" => "42"})
result["account_name"]  #=> "Acme"
result["user_id"]       #=> "42"

Custom Validators

The :validate option accepts a function (arity 1) or a list of functions. Each function receives the validated value and must return one of:

  • :ok -- value passes; the original value is kept.
  • {:ok, new_value} -- value passes; new_value replaces the original.
  • {:error, message} -- validation fails with the given message string.

Validators run after type checking and constraint validation. When a list of validators is given, they execute in order and short-circuit on the first error.

Sinter.Schema.define([
  {:email, :string,
   [
     required: true,
     validate: fn value ->
       if String.contains?(value, "@"),
         do: {:ok, value},
         else: {:error, "must contain @"}
     end
   ]},

  {:code, :string,
   [
     required: true,
     validate: [
       fn v -> if String.length(v) > 0, do: :ok, else: {:error, "cannot be empty"} end,
       fn v -> if String.length(v) <= 10, do: :ok, else: {:error, "too long"} end
     ]
   ]}
])

If a validator raises an exception, the error is caught and wrapped as a :custom_validation_error.

Constraints

Constraints are specified alongside other field options. They are validated after the type check passes.

String and Array Length

{:name, :string, [required: true, min_length: 2, max_length: 50]}
{:tags, {:array, :string}, [optional: true, min_items: 1, max_items: 10]}

Numeric Bounds

ConstraintMeaning
:gtGreater than
:gteqGreater than or equal to
:ltLess than
:lteqLess than or equal to
{:age, :integer, [required: true, gteq: 0, lt: 150]}
{:score, :float, [required: true, gt: 0.0, lteq: 100.0]}

Format (Regex)

{:email, :string, [required: true, format: ~r/^[^\s]+@[^\s]+\.[^\s]+$/]}
{:slug, :string, [required: true, format: ~r/^[a-z0-9\-]+$/]}

Choices

{:status, :string, [required: true, choices: ["active", "inactive", "pending"]]}
{:priority, :integer, [required: true, choices: [1, 2, 3]]}

Nested Objects

Use Sinter.Schema.object/1 to build a nested object type from field specs. It returns an {:object, schema} tuple suitable for use as a field type.

address_type = Sinter.Schema.object([
  {:street, :string, [required: true]},
  {:city, :string, [required: true]},
  {:zip, :string, [required: true, format: ~r/^\d{5}$/]}
])

schema = Sinter.Schema.define([
  {:name, :string, [required: true]},
  {:address, address_type, [required: true]}
])

{:ok, result} = Sinter.Validator.validate(schema, %{
  "name" => "Alice",
  "address" => %{"street" => "123 Main St", "city" => "Springfield", "zip" => "62704"}
})

You can also inline the field specs directly as a list, and Sinter will create the nested schema automatically:

Sinter.Schema.define([
  {:user,
   {:object,
    [
      {:name, :string, [required: true]},
      {:email, :string, [required: true]}
    ]}, [required: true]}
])

Or pass a prebuilt Sinter.Schema.t():

inner = Sinter.Schema.define([{:value, :integer, [required: true]}])

Sinter.Schema.define([
  {:nested, {:object, inner}, [required: true]}
])

Schema Configuration

Schema-level options are passed as the second argument to Sinter.Schema.define/2 or via option in a use_schema block.

OptionTypeDefaultDescription
:titleString.t()nilSchema title (appears in JSON Schema output).
:descriptionString.t()nilSchema description.
:strictboolean()falseWhen true, reject data containing fields not defined in the schema.
:post_validate(map() -> {:ok, map()} | {:error, String.t()})nilRuns after all field validation succeeds.
:pre_validate(term() -> term())nilTransforms raw input before validation begins.

Strict Mode

By default Sinter ignores extra fields. Enable strict mode to reject them:

schema = Sinter.Schema.define(
  [{:name, :string, [required: true]}],
  strict: true
)

Sinter.Validator.validate(schema, %{"name" => "Alice", "extra" => "oops"})
#=> {:error, [%Sinter.Error{code: :strict, message: "unexpected fields: [\"extra\"]"}]}

Post-Validation Hook

The :post_validate function receives the fully validated data map and can perform cross-field checks or final transformations. It must return {:ok, data} or {:error, message}.

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, "Passwords do not match"}
    end
  end
)

Pre-Validation Hook

The :pre_validate function receives the raw input and returns transformed data that then enters the normal validation pipeline. Use it to normalize keys, inject computed fields, or strip sensitive data before validation.

schema = Sinter.Schema.define(
  [
    {:full_name, :string, [required: true]},
    {:first_name, :string, [optional: true]},
    {:last_name, :string, [optional: true]}
  ],
  pre_validate: fn data ->
    first = Map.get(data, "first_name", "")
    last = Map.get(data, "last_name", "")
    Map.put(data, "full_name", String.trim("#{first} #{last}"))
  end
)

If the function raises, the error is caught and returned as a :pre_validate_error.

Querying Schemas

Sinter.Schema provides several functions for inspecting a schema at runtime.

Schema.fields/1

Returns the full map of field name to field definition:

fields = Sinter.Schema.fields(schema)
fields["name"].type      #=> :string
fields["name"].required  #=> true

Schema.required_fields/1 and Schema.optional_fields/1

Return lists of field names:

Sinter.Schema.required_fields(schema)  #=> ["name"]
Sinter.Schema.optional_fields(schema)  #=> ["age", "active"]

Schema.field_types/1

Returns a map of field name to type spec:

Sinter.Schema.field_types(schema)
#=> %{"name" => :string, "age" => :integer, "tags" => {:array, :string}}

Schema.constraints/1

Returns a map of field name to constraint keyword list:

Sinter.Schema.constraints(schema)
#=> %{"name" => [min_length: 2, max_length: 50], "score" => [gt: 0, lteq: 100]}

Fields with no constraints return an empty list.

Schema.field_aliases/1

Returns a map of canonical field names to their aliases. Only fields with an :alias option are included:

schema = Sinter.Schema.define([
  {:account_name, :string, [alias: "accountName"]},
  {:user_id, :string, [alias: "userId"]},
  {:name, :string, []}
])

Sinter.Schema.field_aliases(schema)
#=> %{"account_name" => "accountName", "user_id" => "userId"}

Schema.info/1

Returns a summary map with counts, configuration, and metadata:

info = Sinter.Schema.info(schema)

info.field_count      #=> 3
info.required_count   #=> 1
info.optional_count   #=> 2
info.field_names      #=> ["name", "age", "active"]
info.title            #=> "User Schema"
info.description      #=> "Validates user data"
info.strict           #=> false
info.has_post_validation #=> false
info.created_at       #=> ~U[2024-01-15 10:30:00Z]