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

Type expressions over a datamodel document, and ADR-0001 decision 8's read
check.

A **type expression** is one of five things, and the record admits no
sixth:

  * `{:declared, name}` - a name the document's `types` key declares;
  * `{:shape, members}` - an inline, unnamed shape a consumer builds, whose
    members each carry `name`, `type` and `required?` (decision 8 and
    decision 5's grammar, as amended 2026-09-06);
  * one of the nine types the set is closed at (`:string`, `:integer`,
    `:decimal`, `:boolean`, `:datetime`, `:duration`, `:date`, `:object`,
    `:list`);
  * `{:opaque, string}` - a string a consumer carries that names neither,
    which compares by identity and by nothing else;
  * `:unknown` - nothing is known about it.

`parse/2` reads a document's spelling into one of those, in that order of
precedence: the closed set first, so a document that declares a type
called `"string"` does not shadow the scalar, and the declaration is still
reachable by every other read. An inline shape has no document spelling
and `parse/2` never returns one: it enters this module only as an argument
a consumer hands the read check, exactly as `{:opaque, name}` does.
`to_string/1` prints one back for a pane, and `value_kind/1` names the
expression language's value kind for one, the table
`StatifierDatamodel.Index.path_types/1` reads.

## The read check

`satisfies?/3` and `satisfies/3` decide whether a type `held` at a path
can be read where a type `expected` is required, in the record's order:

  1. either side unknown -> satisfied (`:unknown`). Unknown is permissive
     both ways: this package's whole stance is that what the document does
     not say is not thereby wrong.
  2. identity -> satisfied (`:identical`). The same declared name, the same
     type from the closed set, or the same opaque string.
  3. `held` **covers** `expected` member-wise -> satisfied (`:covers`): for
     every member of `expected` with `required?: true`, `held` carries a
     member of the same `name`, itself `required?: true`, whose type
     satisfies the expected member's type under this same check. Otherwise
     `{:missing, names}`, in the expected side's own member order - a
     member `held` does not have, one it declares optional, and one whose
     type does not satisfy are the same failure, and all are named. A held
     member the expectation does not name is ignored, and an expected
     member marked optional is not consulted at all.
  4. otherwise -> `:not_assignable`.

Step 3's members are a declaration's fields or an inline shape's members,
by this table (decision 8 as amended 2026-09-06):

| `held` \ `expected` | a declared record | a declared shape | an inline shape |
|---|---|---|---|
| **a declared record** | identity only | covers, field-wise | covers, member-wise |
| **a declared shape** | identity only | identity only | not assignable |
| **an inline shape** | not assignable | covers, member-wise | covers, member-wise |

The two refusals are the record's nominal rule, not an omission. An inline
shape never satisfies a declared **record**, because a record's identity is
nominal and an inline shape has no name to be that record by. A declared
**shape** held never covers anything but itself, because a shape is a
constraint and not a fact about what is there.

Step 3 reads both sides' `required?`, on decision 8 as amended
2026-09-06. A record that declares a field optional has not promised the
value, and a shape that marks the field required cannot proceed without
it, so the record does not cover the shape. That failure carries the same
`{:missing, names}` the absent field carries, and the vocabulary does not
grow: *missing* means the record does not promise the field, whichever way
it fails to. A consumer that needs to tell absent from present-but-optional
reads the record's declaration for the named field; nothing this relation
decides turns on the difference.

Step 2 is term equality everywhere except the inline arm, where it is
member-set-wise: two inline shapes carrying the same member names with the
same types and the same `required?` are the same type expression however
they are ordered. Order is kept because `{:missing, names}` is rendered in
it, and it is the one place identity is not Elixir's.

That is the whole relation, and the shape of what it leaves out is the
point. There is no record-into-record structural widening: identity is
nominal, so two records with identical fields are two records. There is no
union, no inference, and no host relation - a consumer that widens further
by name, the palette's host relation in `statifier_blocks`, runs its own
step after this one returns not-satisfied, in its own package.

The check compares a field's `type`, which is what the record says. A
`list` field's `item_type` is carried by
`t:StatifierDatamodel.Declarations.field/0` and is not descended into
here: `list` satisfies `list`, whatever the two elements are. Narrowing
that is a decision no record has taken.

Declarations may reference each other, and a cycle between them is a
document a host can write. The check is total over one anyway: a pair of
names already being decided further up the same check is treated as
satisfied rather than re-entered, so an obligation never depends on
itself twice.

## A consumer's own relation above this one

Layering above the check is intended, not a misuse. A consumer may wrap
`satisfies/3` in a relation of its own: a pre-relation that computes what
it holds and what it expects before the call - a template filter chain
resolved to the type it requires and the type it produces, say - and a
post-relation that decides something more after it. The palette's host
relation in `statifier_blocks`, named above, is exactly that pattern.

A wrapper may not weaken the check. Whenever `satisfies/3` answers
not-satisfied, the wrapping relation answers not-satisfied too: a read
this module rejects stays rejected in every package layered over it, and
a wrapper's freedom is to refuse more, never less. Widening a refusal into
an acceptance is a decision about the read check itself, which belongs in
this package's record rather than in a consumer's.

What a wrapper may assume about `t:reason/0`: its shape is public and
matching on it is supported. Reasons may be **added** as records take
decisions, so a consumer matches the ones it renders and passes anything
else to a default clause rather than assuming the list is closed. No
existing reason is removed or re-spelled before 1.0 without a **Breaking**
entry in `CHANGELOG.md` saying what to do about it.

# `member`

```elixir
@type member() :: %{name: String.t(), type: t(), required?: boolean()}
```

One member of an inline shape: its name, its type expression, and whether
the shape promises it.

The three contract-bearing keys of
`t:StatifierDatamodel.Declarations.field/0`, and only those: a member has
no `label`, because nothing renders a member's name but the member's name;
no `one_of`, because a completion hint is drawn from a document and an
inline shape is not written in one; and no `item_type`, because that is a
key of a declaration field. A member's `type` is never `nil` - a spelling
that resolved to nothing is `:unknown`.

# `reason`

```elixir
@type reason() ::
  :unknown | :identical | :covers | {:missing, [String.t()]} | :not_assignable
```

Why a read is satisfied, or why it is not - the same check as
`satisfies?/3`, for a consumer that renders the reason.

# `t`

```elixir
@type t() ::
  {:declared, String.t()}
  | {:shape, [member()]}
  | StatifierDatamodel.Index.type()
  | {:opaque, String.t()}
  | :unknown
```

A type expression: a declared name, an inline unnamed shape, one of the
nine, an opaque string a consumer carries, or unknown.

# `parse`

```elixir
@spec parse(StatifierDatamodel.Declarations.t(), term()) :: t()
```

Reads a document's spelling of a type into a type expression.

Total. The closed set is looked at first, then the declarations; a
non-empty string that names neither is opaque, and anything that is not a
non-empty string is `:unknown`.

A document's `type` and `item_type` keys are strings, so there is no
spelling here for an inline shape and this function never returns one: a
map where a spelling was expected is `:unknown` like any other malformed
value.

    iex> alias StatifierDatamodel.{Declarations, Types}
    iex> declarations = Declarations.from_document(%{"types" => [
    ...>   %{"name" => "cards.card", "kind" => "record", "label" => "Card", "fields" => []}]})
    iex> Types.parse(declarations, "integer")
    :integer
    iex> Types.parse(declarations, "cards.card")
    {:declared, "cards.card"}
    iex> Types.parse(declarations, "Settleable")
    {:opaque, "Settleable"}
    iex> Types.parse(declarations, nil)
    :unknown
    iex> Types.parse(declarations, %{"name" => "amount_cents", "type" => "integer"})
    :unknown

# `satisfies`

```elixir
@spec satisfies(StatifierDatamodel.Declarations.t(), t(), t()) :: reason()
```

The read check, returning the reason.

A `{:declared, name}` naming nothing this document declares is `:unknown`,
which is the same normalization the index gives a reference to an
undeclared name; the four steps then run over what is left.

    iex> alias StatifierDatamodel.{Declarations, Types}
    iex> declarations = Declarations.from_document(%{"types" => [
    ...>   %{"name" => "cards.credit_txn", "kind" => "record", "label" => "Credit transaction",
    ...>     "fields" => [
    ...>       %{"name" => "amount_cents", "type" => "integer", "required?" => true},
    ...>       %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]},
    ...>   %{"name" => "Refundable", "kind" => "shape", "label" => "Refundable",
    ...>     "fields" => [
    ...>       %{"name" => "amount_cents", "type" => "integer", "required?" => true},
    ...>       %{"name" => "settled_at", "type" => "datetime", "required?" => true}]}]})
    iex> Types.satisfies(declarations, {:declared, "cards.credit_txn"}, {:declared, "Refundable"})
    {:missing, ["settled_at"]}
    iex> Types.satisfies(declarations, :date, :date)
    :identical
    iex> Types.satisfies(declarations, {:declared, "cards.credit_txn"}, :unknown)
    :unknown

A field the record declares optional is missing from the read the same way
an absent one is: the record has not promised the value, and the shape
requires it.

    iex> alias StatifierDatamodel.{Declarations, Types}
    iex> optional_authorized_at = fn required? -> Declarations.from_document(%{"types" => [
    ...>   %{"name" => "cards.credit_txn", "kind" => "record", "label" => "Credit transaction",
    ...>     "fields" => [
    ...>       %{"name" => "amount_cents", "type" => "integer", "required?" => true},
    ...>       %{"name" => "currency", "type" => "string", "required?" => true},
    ...>       %{"name" => "authorized_at", "type" => "datetime", "required?" => required?}]},
    ...>   %{"name" => "Settleable", "kind" => "shape", "label" => "Settleable",
    ...>     "fields" => [
    ...>       %{"name" => "amount_cents", "type" => "integer", "required?" => true},
    ...>       %{"name" => "currency", "type" => "string", "required?" => true},
    ...>       %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]}]}) end
    iex> Types.satisfies(optional_authorized_at.(false), {:declared, "cards.credit_txn"}, {:declared, "Settleable"})
    {:missing, ["authorized_at"]}
    iex> Types.satisfies(optional_authorized_at.(true), {:declared, "cards.credit_txn"}, {:declared, "Settleable"})
    :covers

An inline shape is read the same way, in both directions. A fan-out's
collected envelope is assembled by a compiler and declared nowhere, and its
`donedata` member still covers the summary the document does declare.

    iex> alias StatifierDatamodel.{Declarations, Types}
    iex> declarations = Declarations.from_document(%{"types" => [
    ...>   %{"name" => "ChunkSummary", "kind" => "shape", "label" => "Chunk summary",
    ...>     "fields" => [
    ...>       %{"name" => "authorized_count", "type" => "integer", "required?" => true},
    ...>       %{"name" => "declined_count", "type" => "integer", "required?" => true}]}]})
    iex> summary = {:shape, [
    ...>   %{name: "authorized_count", type: :integer, required?: true},
    ...>   %{name: "declined_count", type: :integer, required?: true}]}
    iex> envelope = {:shape, [
    ...>   %{name: "index", type: :integer, required?: true},
    ...>   %{name: "status", type: :string, required?: true},
    ...>   %{name: "donedata", type: summary, required?: false}]}
    iex> Types.satisfies(declarations, summary, {:declared, "ChunkSummary"})
    :covers
    iex> Types.satisfies(declarations, envelope, {:declared, "ChunkSummary"})
    {:missing, ["authorized_count", "declined_count"]}

Identity for the arm is member-set-wise: the same members in another order
are the same type expression.

    iex> alias StatifierDatamodel.Types
    iex> Types.satisfies(%{},
    ...>   {:shape, [%{name: "index", type: :integer, required?: true},
    ...>             %{name: "status", type: :string, required?: true}]},
    ...>   {:shape, [%{name: "status", type: :string, required?: true},
    ...>             %{name: "index", type: :integer, required?: true}]})
    :identical

# `satisfies?`

```elixir
@spec satisfies?(StatifierDatamodel.Declarations.t(), t(), t()) :: boolean()
```

Whether a value of type `held` may be read where `expected` is required.

ADR-0001 decision 8, as `satisfies/3` decides it: true for `:unknown`,
`:identical` and `:covers`, false for `{:missing, _}` and
`:not_assignable`.

    iex> alias StatifierDatamodel.{Declarations, Types}
    iex> declarations = Declarations.from_document(%{"types" => [
    ...>   %{"name" => "cards.credit_txn", "kind" => "record", "label" => "Credit transaction",
    ...>     "fields" => [%{"name" => "amount_cents", "type" => "integer", "required?" => true}]},
    ...>   %{"name" => "Settleable", "kind" => "shape", "label" => "Settleable",
    ...>     "fields" => [%{"name" => "amount_cents", "type" => "integer", "required?" => true}]}]})
    iex> Types.satisfies?(declarations, {:declared, "cards.credit_txn"}, {:declared, "Settleable"})
    true
    iex> Types.satisfies?(declarations, :integer, :string)
    false

# `scalar`

```elixir
@spec scalar(term()) :: t() | nil
```

The type in the closed set that `spelling` names, or `nil`.

The closed-set half of the grammar, on its own, for a caller resolving a
document's spellings before the declarations are known -
`StatifierDatamodel.Declarations` resolves a field's type with it.

    iex> StatifierDatamodel.Types.scalar("date")
    :date
    iex> StatifierDatamodel.Types.scalar("cards.card")
    nil
    iex> StatifierDatamodel.Types.scalar(7)
    nil

# `to_string`

```elixir
@spec to_string(t()) :: String.t()
```

Prints a type expression the way a document spells it.

For a pane that renders a type beside a path. It is a rendering and not an
identity: `:unknown` prints as `"unknown"`, which an opaque string is free
to spell too, and `parse/2` is the only reader of a document's spelling.

An inline shape has no document spelling to print back, so it renders as
its members in their own order, each `name: type`, with a `?` after the
name of a member the shape does not promise.

    iex> alias StatifierDatamodel.Types
    iex> Types.to_string({:declared, "cards.credit_txn"})
    "cards.credit_txn"
    iex> Types.to_string(:datetime)
    "datetime"
    iex> Types.to_string({:opaque, "Settleable"})
    "Settleable"
    iex> Types.to_string(:unknown)
    "unknown"
    iex> Types.to_string({:shape, [
    ...>   %{name: "index", type: :integer, required?: true},
    ...>   %{name: "donedata", type: {:declared, "ChunkSummary"}, required?: false}]})
    "{index: integer, donedata?: ChunkSummary}"

# `value_kind`

```elixir
@spec value_kind(t() | nil) :: StatifierDatamodel.Index.kind() | nil
```

The expression language's value kind for a type expression, or `nil`.

The answer `StatifierDatamodel.Index.path_types/1` gives for an entry of
that type: `:string` is `:string`; `:integer` and `:decimal` are both
`:number`; `:boolean`, `:date`, `:datetime` and `:duration` are
themselves. `:object` and `:list`, a declared name, an inline shape, an
opaque string, `:unknown` and `nil` have no kind and answer `nil`, as the
same entry is absent from `path_types/1`'s map: absence means unknown, not
wrong.

    iex> alias StatifierDatamodel.Types
    iex> Types.value_kind(:date)
    :date
    iex> Types.value_kind(:decimal)
    :number
    iex> Types.value_kind({:declared, "library.loan"})
    nil

---

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