07. Relationships
Values relate in exactly two ways (D26): composition — ownership,
forming a tree — and reference — ref<T>, forming graphs. This
chapter defines canonical paths, navigation and context variables,
reference construction and integrity, and the reverse query $referrers.
7.1 Composition and reference
Section titled “7.1 Composition and reference”| Composition (default) | Reference (ref<T>) |
|
|---|---|---|
| A property’s value is | owned — part of this value | a pointer to a value owned elsewhere |
| Shape | tree: single parent | graph: shared targets, cycles allowed |
| Cycles | impossible (the member dependency graph is acyclic, D23) | allowed |
| Serialized as | the value itself | a canonical path string (D29) |
| Missing target | — | dangling-reference validation error |
Values are immutable; composition therefore has copy semantics with no observable sharing.
7.2 Canonical paths
Section titled “7.2 Canonical paths”A path names a location under an evaluation root:
path = root-name segment*segment = "." identifier // record member (identifier-shaped name) | "[" int "]" // array index (0-based) | "[" string "]" // map key, or record member with a non-identifier namedemo.services[2].port,registry["svc-a"].endpoint- Canonical form mirrors the access rules of the expression surface
(§4.3): a record member is
.namewhen its name is dot-spellable (identifier-shaped and not a literal keywordtrue/false/null— §3.11) and["…"]otherwise; a map key is always["…"], even when identifier-shaped; array indices are[int]; no spaces. So.in a path always means a declared record member, and no location has two canonical spellings. Serialization and diagnostics emit exactly this form, and a reference path string from input data must be canonical — a non-canonical spelling (demo["services"]) does not resolve (§7.5). - Document-relative form: in serialized reference strings and
bound documents only, the root may be spelled
$— “the evaluation root this document is bound to” ("$.services[0]") — keeping documents self-contained and independent of the slot name they bind to (10. Interchange).$is a wire form: the location it denotes is an absolute path, and reference identity, place equality, diagnostics, and path ordering always use the absolute form. - Canonical path order — the total order used for
$referrersresults (§7.6) and diagnostic sorting (§6.7) — compares segment-wise: root names lexicographically (by Unicode code point), member and key segments lexicographically, array indices numerically ([2] < [10]), and a shorter path that is a prefix of a longer one precedes it. Byte comparison of path strings is not the order ([10]would sort before[2]).
7.3 Navigation and context variables
Section titled “7.3 Navigation and context variables”Member expressions (defaults, derived members, constraint conditions)
navigate with ., […], ?. under the rules of
04. Expressions, starting from sibling names or
from a context variable:
| Variable | Meaning | Type |
|---|---|---|
$this |
the record instance whose member is being evaluated | ref<enclosing type> |
$parent |
the instance that owns $this |
ref<declared bound> (D30) |
$root |
the evaluation root’s instance | ref<declared bound> (D30) |
$key |
the key or index under which $this sits in its parent collection |
string (map) / int (array) — a plain value |
$path |
the canonical path of $this |
string |
$referrers |
reverse query, §7.6 | — |
$this, $parent, and $root are references, necessarily: each
denotes an instance that (transitively) contains $this, so a value
reading would be a value containing itself — the value cycle D26
forbids. Member access reads through them transparently (§7.4), a
member holding one (const up = $parent) serializes as a canonical
path, and equality is place equality. $key alone is an ordinary
value.
-
A bare name
xin a member expression resolves nearest-enclosing-instance-first: the members of$this, then of$parent, and so on up the ownership chain, before module and imported names — the one sanctioned lookup-order exception to the no-shadowing rule (D27). It is resolution order over coexisting name spaces, not a rebinding, and the outer name stays addressable outside member expressions. The chain (not just siblings) is what lets a nested literal reach its container’s entries —source: ports["si0"]inside an edge literal reaches the enclosing node’sports(§4.2); sibling-only resolution would contradict this chapter’s own §7.4 example, a defect the §0.6 evaluator spike caught in execution. -
Context declarations — obligations are declared, not inferred (D30). A named type whose member expressions use
$parent,$root, or$keymust declare them, with the member syntax and the context variable as the name:type Port = {$parent: ref<{ data_width: DataWidth, ... }> // what my owner must offername: PortNamewidth?: int = $parent.data_width // $parent has exactly the written type}type Router = {data_width: DataWidthports: Port[] // site check: Router ⊑ Port's $parent bound — ok}$keydeclares at its plain value type (noref— a key does not contain$this), and the site check runs against the embedding collection’s key or index type:type NamedPort = {$key: /[sm]i[0-9]+/ // I live in a map keyed by port namesname = $key // the entry knows its own keywidth?: Width = 1}type Node = {ports: { [/[sm]i[0-9]+/]: NamedPort } // key type ⊑ the bound — ok}// items: NamedPort[] — site error: an array's index type int ⋢ the pattern(There is no
$value: a map entry’s value is the instance — that is$this; and the checked value of a predicate type is that predicate function’s explicitly typed parameter, §3.7.)- What you write is the variable’s type (D2):
$parentand$rootdeclarations are writtenref<P>— the reference-ness is an invariant (the table above), and the language makes you write the invariant rather than hiding it: a bare non-refbound ($parent: { … }) is a compile error whose message supplies theref<…>form. No ambiguity arises: an owner is always a record instance, never itself a reference, so a nestedref<ref<…>>bound has no meaning to collide with.$keyis declared at its plain value type. The type’s body is checked once, at its declaration — fully modular. The embedding-site check reduces to one subsumption test: the embedding record’s type (for$parent), the root’s type (for$root), or the collection’s key/index type (for$key) must be⊑the bound’s target. A site that fails says so at that site, naming both types. - Structural typing keeps declarations decoupled: declare the minimal
open record you actually read (
{ data_width: DataWidth, ... }), not some specific owner — any embedder offering it qualifies. - Using a context variable in a named type with no declaration for it
is a compile error at the type’s declaration. Exception: a type
expression lexically nested inside its parent’s own declaration
(an inline member type, an inline extension) needs no declaration —
its parent is the enclosing record type, statically evident on the
page (case 1’s arbiter override reads
$parent.insthis way). - At most one declaration per variable;
$pathand$thistake none (alwaysstringand the enclosing type). Inheritance may narrow a context declaration (⊑only); intersection conjoins both sides’. Context declarations are not members: they are not data, never serialize, cannot be set or restated, and live in the$name space where no member name can collide. - A site that gives a variable no meaning at all —
$parentwhere the type is the evaluation root’s own type,$keywhere the immediate owner is not a collection element — remains a compile error at that site.
- What you write is the variable’s type (D2):
7.4 Reference values
Section titled “7.4 Reference values”How a reference is written: in a ref<T>-typed position, a
navigation expression denotes the reference itself — the location,
not a copy. No marker syntax exists or is needed: a ref<T> position
cannot hold a T value, so there is no ambiguity to resolve (the same
type-directed reading that lets 10ms be a quantity).
output net: Network = { services: [ { name: "a" }, { name: "b" } ] links: [ { source: services[0], target: services[1] } // references, not copies ]}-
Statically, the navigated location’s type must be
⊑ T. -
A conditional between places is a place (v0.2.1): in a
ref<T>position,if c then a.ports[k] else b.sparereads each branch as a navigation — the expected type decides per branch, as ever — and resolves only the taken one; the condition is an ordinary value expression. A branch that is not a navigation is an error at that branch. -
In the interchange surface, the same reference is the canonical path string (
"net.services[0]") — the input-binding form and the serialized form (D29, 10. Interchange); the duality mirrors quantities (source literal vs interchange object, D15). -
Using a reference: member access and indexing navigate through it transparently (
link.source.namereads the target’s member); spread ({ ...link.source }) copies the target’s entries. -
Type-directed dereference — the mirror rule. Just as a navigation in a
ref<T>position denotes the reference, a reference in aT-typed position denotes the target’s value:neighbors: Service[] = [l.source for l in inbound]first: Service = inbound[0].sourceThe expected type decides, in both directions; without an expected value type, a reference stays a reference. Dereference happens only where a declaration asks for the value — never silently. A dangling reference reported under §7.5 taints its dereference (§6.6).
-
In every other position the reference behaves as a reference value: equality compares canonical target paths (§4.5), and serialization emits the path.
-
ref<T₁> ⊑ ref<T₂>iffT₁ ⊑ T₂(§3.17).
7.5 Reference integrity
Section titled “7.5 Reference integrity”- Legal targets are values owned by evaluation roots —
outputandinputvalues and their sub-values (D22). A moduleconstis not a legal target, and neither is a hidden member’s value (§5.7 — it is in no document, so no path could name it); a navigation of either in arefposition is a compile error (E4093). Embedding aconst-built record into a root does not re-root the references it carries (D32) — a record meant for several roots comes from a constructorfuncwhose literal each root binds in place. Cross-root references (a value in one output referencing another output’s sub-value, or an input’s) are legal. - A reference whose target does not exist — an out-of-range index, a missing key, a path into an absent optional member, or (from input data) a path that does not resolve — is a dangling-reference validation error at the reference’s path.
- A reference whose target exists but fails its type is not dangling; the target’s own diagnostics stand, and the reference is tainted by them under root-cause rules (§6.6) — no second report.
- Reference cycles are permitted and terminate: navigation is demand-driven, and constraint evaluation visits each (rule, instance) pair once (09. Semantics).
7.6 Reverse queries: $referrers
Section titled “7.6 Reverse queries: $referrers”The premise comes first: in Decl, a reference is always owned by a record instance — it sits in some member, directly or inside the arrays/maps under that member (§7.5, and the boundary bullet below). A reverse lookup therefore has a natural, fully determined shape: name whose reference and which member carries it, and ask who points here through that edge.
$referrers(T, "m") // "the T instances whose member m references me"The two arguments are the two halves of a relationship edge — read them the way an association end or an ORM reverse relation is read:
T— who refers: the referrer’s declared type. It is also what types the result (ref<T>[])."m"— through what: the member that carries the reference; the edge’s name.
type Link = { source: ref<Service> target: ref<Service>}
type Service = { name: ServiceName
inbound = $referrers(Link, "target") // Links pointing at me via target outbound = $referrers(Link, "source") // …and via source
assert not_isolated: std.array.count(inbound) > 0 else warn `service ${name} has no inbound links`}-
$referrers(T, "m")— every value in the evaluation universe that (a) occupies a position whose declared type is⊑ T— declared positions, not structural coincidence: a value that merely sharesT’s shape is not a candidate — and (b) whose membermcontains a reference to$this(place equality, §7.4). -
“Contains” traverses collections, not records. The reference need not be
m’s direct value:m: ref<S>, an array element (m: ref<S>[]), a map value (m: { [string]: ref<S> }), and any nesting of arrays and maps undermall count — the edge is still “throughm”. It does not descend into nested records: a reference inside a record undermis owned by that record, which is then the referrer to query with its own type. An absent optionalmrefers to nothing.type Hub = { spokes: ref<Service>[] }type Service = {hubs = $referrers(Hub, "spokes") // Hubs listing me among spokes} -
Static checks — both fall out of
T’s declaration alone:Tmust be a record type, and"m"a string literal naming a member ofT— a hidden one included ("target$") — whose type contains at least onerefposition compatible with the enclosing type; anything else is a compile error (a typo in the edge name cannot silently return an empty answer). The same property lets implementations maintain a reverse index per(T, m)edge. -
Combining edges is explicit: a union of edges is a spread —
[...inbound, ...outbound]— and ad-hoc conditions on the referrers are ordinary filters over the result ([l for l in inbound if l.weight > 2]). -
Referrers are record instances — by premise. A reference that no record owns — a
ref-typed root (output primary: ref<Service>), or a bare collection of references (output watchlist: ref<Service>[]) — is not found by$referrers: there is no owner value to return. Such sites are still queryable, because they are nameable —$this in watchlist— and a model that wants them reverse-discoverable should wrap the reference in a record (an owner also gives it a place for constraints and metadata):type Watch = { service: ref<Service>, since: string }. -
The result is
ref<T>[]— distinct referrers (one entry per referring value, however many of its references point at$this) in canonical path order (§7.2), defined identically for language-declared and input-bound data (V6). -
The evaluation universe is fixed and explicit: all evaluation roots of the module set being evaluated — every
outputand every boundinput— regardless of which root the tool ultimately serializes, so the answer never depends on what a tool happens to demand (09. Semantics §9.2). -
A candidate whose member
mis invalid is excluded silently under root-cause rules (§6.6); filters over the result follow the ordinary taint rules. -
$referrersand laziness: the query is answerable only after every instance of the universe is materialized — a demand-driven implementation must defer$referrers-dependent members (and everything that transitively reads them) until materialization completes, or the same document could yield different answers depending on demand order, violating observational equivalence (§9.4). The §0.6 spike hit this as a live bug: a width forced mid-materialization memoized an empty referrer set. -
$referrersis the only universe query. Ad-hoc relationship constraints — uniqueness, degree rules, joins — belong to the container type that owns the collections involved, filtering named collections with ordinary comprehensions and place equality (§4.5); there is no ambient “all values of T” enumeration form.
Open questions
Section titled “Open questions”None.
Previous / Next
Section titled “Previous / Next”- Previous: 06. Constraints and Diagnostics
- Next: 08. Modules and Packages
- Index: Documentation home
© 2026 Luuvish. Decl is open source under the MIT License.
Type: Literata and IBM Plex, under the SIL Open Font License. Built with Astro Starlight.