Skip to content
Noodle
InstallLearnPlayground
GitHub

Structural capabilities

Structural capabilities are the standard library’s reusable operations for values whose shape can be inspected. They power equality assertions, ordered collections, hash-based algorithms, and diagnostic output without making every datatype implement the same code by hand.

The four public interfaces are:

  • IEqual, whose method is equal(left, right) -> Bool;
  • ICompare, whose method is compare(left, right) -> Int;
  • IHash, whose method is hash(value) -> Int;
  • IDebug, whose method is to_repr(value) -> Debug.Repr.

Prelude re-exports these interfaces and the four named structural helpers:

Public Prelude helper Result
Prelude.structural_equal(left, right) Bool
Prelude.structural_compare(left, right) -1, 0, or 1
Prelude.structural_hash(value) signed Int hash
Prelude.structural_debug_repr(value) lazy Debug.Repr

These are public functions in the Prelude module. A qualified call makes the API boundary explicit; a direct call is useful when the concrete type is known. Generic code normally asks for an interface provider instead; that keeps the relationship between the value and its capability explicit. The underlying Equal, Compare, and Hash modules are standard-library implementation modules; application code should use the Prelude helpers.

The generated operations work recursively for ordinary structural values. This small example compares the fields of two records, orders them, hashes one, and renders it for a diagnostic:

func main() -> Unit do
first = {x: 2, y: 3};
second = {x: 2, y: 4};
equal = Prelude.structural_equal(first, second);
order = Prelude.structural_compare(first, second);
digest = Prelude.structural_hash(first);
rendered = Debug.to_debug_string(first);
Console.println(if equal then "equal" else "different" end);
Debug.trace(order);
Debug.trace(digest);
Console.println(rendered);
end

structural_compare returns only the sign of the ordering. Records and tuples are compared lexicographically in canonical field order, so the first field that differs determines the result. The same field order is used for hashing.

An unconstrained type parameter does not automatically have equality, ordering, hashing, or debugging operations. Add a contextual extension question when a generic function needs one:

func same[T](
left : T,
right : T,
?{extension EqualForT: IEqual for T},
) -> Bool do
EqualForT.equal(left, right)
end
func main() -> Unit do
point = {x: 4, y: 9};
Debug.trace(same(point, {x: 4, y: 9}));
end

The EqualForT binding is an interface dictionary selected at the call site. The standard library’s StructuralEqual[T] provider can satisfy this question when the selected type is structurally supported. The same pattern works with ICompare, IHash, and IDebug; see Question parameters for the syntax and Interfaces and extensions for provider resolution.

The generated operations recurse through these representations:

  • Unit, Bool, Int, String, Char, and Double use their primitive semantics. Hashing follows the standard library’s deterministic rules, including UTF-16 string contents and the binary representation of Double.
  • Records and tuples visit fields in canonical order.
  • Immutable Array[T] values compare length and then elements in index order. Their standard-library provider asks for the corresponding capability for T.
  • Ordinary datatypes compare constructor identity first and then payloads in declaration order. Named payloads retain their field labels in debug output.
  • Error-type constructors use their stable constructor tags and payloads, including constructors inherited into a derived error set.
  • The standard Option[T] and Result[T, E] views delegate to these same structural operations, recursively requiring capabilities for their payload types.

For equality, equal values always have equal structural hashes. Structural hashes are deterministic for structural values. Values that use runtime identity, such as functions and @identity datatypes, have identity-based hashes that are not stable across process executions.

Here is a datatype whose constructor payloads are compared recursively:

datatype Tree
Leaf(Int)
Branch(Tree, Tree)
end
func main() -> Unit do
left = Tree::Branch(Tree::Leaf(1), Tree::Leaf(2));
right = Tree::Branch(Tree::Leaf(1), Tree::Leaf(3));
Debug.trace(Prelude.structural_equal(left, right));
Debug.trace(Prelude.structural_compare(left, right));
Debug.trace(Debug.to_debug_string(left));
end

Constructor order is declaration order. In this example Leaf precedes Branch, and two Branch values are compared by their first payload before their second payload.

Prelude.structural_debug_repr returns a lazy Debug.Repr, not a String. Debug.to_debug_string renders it, and Debug.trace renders and prints it. The representation keeps container bodies as indexed callbacks so a renderer does not have to materialize every element before it knows how much output is needed.

The standard renderer is deliberately bounded: it expands at most 64 elements from one container and stops descending after depth 8. Omitted elements and deep recursive values are shown as .... Strings and characters are quoted. This makes debug output useful for failures and logs, but it is not a stable serialization format and should not replace a domain-specific to_string or JSON encoder.

datatype Event
Event{name: String, code: Int}
end
func main() -> Unit do
event = Event::Event{name: "ready", code: 200};
Debug.trace(event);
Console.println(Debug.to_debug_string([event, event]));
end

StructuralEqual, StructuralCompare, StructuralHash, and StructuralDebug are ordinary associated extensions. The interfaces associate these providers with their own interface identities, which lets a suitable provider be considered for a contextual question or method call. They are not a magical universal fallback: an unrelated interface or an unassociated extension does not become available merely because its module is visible.

When a concrete type reaches a structural entry point, the compiler generates and caches a specialized implementation for that type. A generated implementation can contain calls to the same operation for nested fields, so a record containing an Option[Array[Int]] produces a recursive capability requirement for each layer. The generated function is an implementation detail; it is not a first-class function value and can only be called directly.

The standard library also provides explicit primitive and collection extensions. For example, Array[T] uses an IEqual for T, ICompare for T, IHash for T, and IDebug for T question to implement its own provider. This is why a generic array algorithm can remain type-safe without assuming that every possible T supports every operation.

Some values intentionally do not expose their representation:

  • A function is compared, ordered, and hashed by runtime identity. Its debug representation is opaque (<function>).
  • A datatype marked @identity is an indivisible reference identity: equality, comparison, and hashing compare the value itself rather than its payload. Debug output is a separate capability, so its representation still renders the datatype’s owner-visible payload fields structurally, like any other visible datatype. Only an opaque @identity datatype falls back to an explicit IDebug provider, following the ordinary opaque-datatype visibility rules.
  • MutArray[T] is identity-based and is shown as an opaque MutArray value; use an immutable snapshot when a value should participate in structural collection operations.
  • An opaque datatype imported from another module cannot be structurally traversed by consumers. Its defining module must provide the public capability extensions that callers are meant to use.
  • Interface dictionaries and other unsupported native values have no structural equality, comparison, hashing, or debug fallback in this language version. A concrete specialization that needs one is diagnosed rather than silently converted.

Use an explicit domain operation when identity is the intended meaning. Use a public extension when an opaque type should expose a stable comparison, serialization, or diagnostic policy without exposing its internal fields.

The Prelude and modules page lists the capability aliases, and Output, debugging, and tests shows how Debug and Testing.assert_equal consume IEqual and IDebug.

Next: Advanced pattern matching.