Type expressions over a datamodel document, and ADR-0001 decision 8's read check.
A type expression is one of four things, and the record admits no fifth:
{:declared, name}- a name the document'stypeskey declares;- 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. to_string/1 prints one back for a pane.
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:
- 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. - identity -> satisfied (
:identical). The same declared name, the same type from the closed set, or the same opaque string. heldnames a record andexpectednames a shape -> satisfied when the record's fields cover the shape's required set (:covers): for every field of the shape withrequired?: true, the record has a field of the samename, itselfrequired?: true, whose type satisfies the shape field's type under this same check. Otherwise{:missing, names}, in the shape's own field order - a field the record does not have, a field the record declares optional, and a field whose type does not satisfy are the same failure, and all are named. A record field the shape does not name is ignored, and a shape field the shape marks optional is not consulted at all.- otherwise ->
:not_assignable.
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.
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
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.
Summary
Types
Why a read is satisfied, or why it is not - the same check as
satisfies?/3, for a consumer that renders the reason.
A type expression: a declared name, one of the nine, an opaque string a consumer carries, or unknown.
Functions
Reads a document's spelling of a type into a type expression.
The read check, returning the reason.
Whether a value of type held may be read where expected is required.
The type in the closed set that spelling names, or nil.
Prints a type expression the way a document spells it.
Types
@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.
@type t() :: {:declared, String.t()} | StatifierDatamodel.Index.type() | {:opaque, String.t()} | :unknown
A type expression: a declared name, one of the nine, an opaque string a consumer carries, or unknown.
Functions
@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.
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
@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)
:unknownA 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
@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
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
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.
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"