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'stypeskey declares;{:shape, members}- an inline, unnamed shape a consumer builds, whose members each carryname,typeandrequired?(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.
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. heldcoversexpectedmember-wise -> satisfied (:covers): for every member ofexpectedwithrequired?: true,heldcarries a member of the samename, itselfrequired?: 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 memberhelddoes 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.- 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
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
One member of an inline shape: its name, its type expression, and whether the shape promises it.
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, an inline unnamed shape, 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
One member of an inline shape: its name, its type expression, and whether the shape promises it.
The three contract-bearing keys of
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.
@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()} | {: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.
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.
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
@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"})
:coversAn 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
@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.
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}"