# `StatifierDatamodel.Conformance`
[🔗](https://github.com/riddler/statifier_datamodel/blob/v0.7.1/lib/statifier_datamodel/conformance.ex#L1)

ADR-0004 decision 7: whether a value conforms to a type expression.

`conforms/3` is given the declarations, a type expression and a value -
a payload a host decoded from JSON or built in Elixir - and answers `:ok`,
or `{:error, mismatches}` naming every place the value does not conform.
It checks against a declared record, a declared shape or an inline shape
alike: a payload is checked against what its event declares it carries.

`StatifierDatamodel.Types.satisfies/3` decides a type against a type, and
`StatifierDatamodel.Coverage.missing/3` decides which required fields of a
shape a map leaves unfilled. This module is the third question beside
them, a value against a type, and neither of the other two changes any
answer it gives.

## A mismatch: a path and a reason

Each mismatch is `{path, reason}`. `path` is a list of segments, a string
for a field name and an integer for a list index; the value itself is at
`[]`. `reason` is one of two:

  * `:missing` - a required field is absent or `nil`, which is
    `StatifierDatamodel.Coverage.missing/3`'s fill rule;
  * `{:expected, type}` - the value at `path` does not conform to `type`,
    the type expression it failed.

Mismatches come in the type's field order, depth first, and a list's
items in index order.

## What each type accepts

| Type | A value conforms when it is |
|---|---|
| `:string` | a binary |
| `:integer` | an integer |
| `:decimal` | an integer, a float, a `Decimal` struct, or a string |
| `:boolean` | a boolean |
| `:date` | an ISO 8601 date string, or a `Date` |
| `:datetime` | an ISO 8601 date-time string carrying an offset, or a `DateTime` |
| `:duration` | an ISO 8601 duration string, an expression-language duration string, or a `Duration` |
| `:object` | a map |
| `:list` | a proper list, whose items conform to the field's `item_type` when it is known |
| a declared record or shape, or an inline shape | a map whose fields conform, below |
| `:unknown`, `{:opaque, _}`, a declared name the declarations do not hold, or `nil` | anything |

A `Decimal` struct is recognised by its module name; the package takes no
dependency on it. The string spellings of `:decimal` and `:duration` are
ADR-0001 decision 4's, accepted beside the ones a decoded JSON value
spells. Decision 4 says a `decimal` "is a string" and states no grammar
for it, so any string conforms. An expression-language duration string
is one or more `<digits>(.<digits>)?<unit>` pairs, the unit one of `y`,
`mo`, `w`, `d`, `h`, `m`, `s` and `ms`, with no whitespace and no sign,
and a fraction that comes to a whole number of milliseconds (`30s`,
`1h30m`, `1.5s`): the rule the expression language's own duration parse
applies to a whole string, followed here without depending on it.

A `NaiveDateTime` carries no offset and does not conform to `:datetime`,
and neither does a date-time string without one. A field whose type, or a
`list` field whose `item_type`, named nothing the document declares is
read as unknown, as an undeclared name is everywhere in this package.

## A record, a shape or an inline shape

The value must be a map, read with the strings the declaration spells, as
`StatifierDatamodel.Coverage.missing/3` reads one: a key spelled as an atom
is not the field's key. Then each field, in the type's order:

  * absent or `nil` - a `:missing` mismatch when the field is required,
    and nothing when it is optional;
  * present - checked against the field's type, and a `list` field's
    items against its `item_type`, with the field's name, and an item's
    index, added to the path.

A key the type does not name is ignored, because every object is open, and
a field's `one_of` is a hint and is not checked. A record and a shape are
checked the same way: the difference between them is what the read check
decides, not what a value carries.

Every answer is a fact. Nothing raises, nothing is enforced at run time,
and whether a mismatch stops a host's pipeline is the host's to decide
(ADR-0001 decision 12).

# `mismatch`

```elixir
@type mismatch() :: {path(), reason()}
```

One place a value does not conform to its type.

# `path`

```elixir
@type path() :: [String.t() | non_neg_integer()]
```

Where a mismatch is: a field name or a list index per segment, `[]` the value itself.

# `reason`

```elixir
@type reason() :: :missing | {:expected, StatifierDatamodel.Types.t()}
```

Why the value at a path does not conform.

# `conforms`

```elixir
@spec conforms(
  StatifierDatamodel.Declarations.t(),
  StatifierDatamodel.Types.t(),
  term()
) ::
  :ok | {:error, [mismatch(), ...]}
```

Whether `value` conforms to `type`: `:ok`, or every mismatch in order.

The library loan. A renewal is a record whose holds are a list of another
record:

    iex> alias StatifierDatamodel.{Conformance, Declarations}
    iex> declarations = Declarations.from_document(%{"types" => [
    ...>   %{"name" => "loans.renewal", "kind" => "record", "label" => "Renewal", "fields" => [
    ...>     %{"name" => "copy_id", "type" => "string", "required?" => true},
    ...>     %{"name" => "due_on", "type" => "date", "required?" => true},
    ...>     %{"name" => "renewals", "type" => "integer", "required?" => true},
    ...>     %{"name" => "holds", "type" => "list", "item_type" => "loans.hold"}]},
    ...>   %{"name" => "loans.hold", "kind" => "record", "label" => "Hold", "fields" => [
    ...>     %{"name" => "patron_id", "type" => "string", "required?" => true}]}]})
    iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
    ...>   %{"copy_id" => "c-0412", "due_on" => "2026-11-02", "renewals" => 1})
    :ok
    iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
    ...>   %{"copy_id" => "c-0412", "due_on" => ~D[2026-11-02], "renewals" => 1})
    :ok
    iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
    ...>   %{"copy_id" => "c-0412", "due_on" => "next week", "renewals" => nil,
    ...>     "holds" => [%{"patron_id" => "p-77"}, %{}], "shelf" => "B3"})
    {:error, [{["due_on"], {:expected, :date}},
              {["renewals"], :missing},
              {["holds", 1, "patron_id"], :missing}]}

An inline shape is checked the same way, and a scalar on its own is the
value at `[]`:

    iex> alias StatifierDatamodel.Conformance
    iex> Conformance.conforms(%{}, {:shape, [%{name: "patron_id", type: :string, required?: true}]},
    ...>   %{"patron_id" => 77})
    {:error, [{["patron_id"], {:expected, :string}}]}
    iex> Conformance.conforms(%{}, :duration, "1h30m")
    :ok
    iex> Conformance.conforms(%{}, :datetime, ~N[2026-11-02 10:00:00])
    {:error, [{[], {:expected, :datetime}}]}
    iex> Conformance.conforms(%{}, :unknown, {:anything, "at all"})
    :ok

---

*Consult [api-reference.md](api-reference.md) for complete listing*
