Skip to content

07. Relationships

Values relate in exactly two ways (D26): composition — ownership, forming a tree — and referenceref<T>, forming graphs. This chapter defines canonical paths, navigation and context variables, reference construction and integrity, and the reverse query $referrers.

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.

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 name
  • demo.services[2].port, registry["svc-a"].endpoint
  • Canonical form mirrors the access rules of the expression surface (§4.3): a record member is .name when its name is dot-spellable (identifier-shaped and not a literal keyword true/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 $referrers results (§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]).

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 x in 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’s ports (§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 $key must declare them, with the member syntax and the context variable as the name:

    type Port = {
    $parent: ref<{ data_width: DataWidth, ... }> // what my owner must offer
    name: PortName
    width?: int = $parent.data_width // $parent has exactly the written type
    }
    type Router = {
    data_width: DataWidth
    ports: Port[] // site check: Router ⊑ Port's $parent bound — ok
    }

    $key declares at its plain value type (no ref — 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 names
    name = $key // the entry knows its own key
    width?: 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): $parent and $root declarations are written ref<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-ref bound ($parent: { … }) is a compile error whose message supplies the ref<…> form. No ambiguity arises: an owner is always a record instance, never itself a reference, so a nested ref<ref<…>> bound has no meaning to collide with. $key is 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.ins this way).
    • At most one declaration per variable; $path and $this take none (always string and 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 — $parent where the type is the evaluation root’s own type, $key where the immediate owner is not a collection element — remains a compile error at that site.

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.spare reads 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.name reads 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 a T-typed position denotes the target’s value:

    neighbors: Service[] = [l.source for l in inbound]
    first: Service = inbound[0].source

    The 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₂> iff T₁ ⊑ T₂ (§3.17).

  • Legal targets are values owned by evaluation roots — output and input values and their sub-values (D22). A module const is 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 a ref position is a compile error (E4093). Embedding a const-built record into a root does not re-root the references it carries (D32) — a record meant for several roots comes from a constructor func whose 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).

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:

  • Twho 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 shares T’s shape is not a candidate — and (b) whose member m contains 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 under m all count — the edge is still “through m”. It does not descend into nested records: a reference inside a record under m is owned by that record, which is then the referrer to query with its own type. An absent optional m refers 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: T must be a record type, and "m" a string literal naming a member of T — a hidden one included ("target$") — whose type contains at least one ref position 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 output and every bound input — 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 m is invalid is excluded silently under root-cause rules (§6.6); filters over the result follow the ordinary taint rules.

  • $referrers and 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.

  • $referrers is 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.

None.


© 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.