Records
Partly builtStructural product types: declaration, construction, dot access, and the update-spread — all real, all UCM-verified.
A record is a named product type: a fixed set of fields, each with its own
type. Records are structural — a record’s identity is a pure function of
its declared shape (its field names and types), never a process-generated
identifier. Two record declarations with identical field names and types
genuinely share a hash: the same “local names are not identity” principle
Clay’s terms already have (add and plus, defined identically, share one
object today), extended to record types.
This landed 2026-09-26, right after abilities. Declaration, construction,
.field access, and the update-spread all work end to end, oracle-verified
against UCM at every step, needing zero new hashing primitives — every
shape a record’s construction, its generated accessor, and its generated
setter need turned out to already be exactly what abilities’ own hashing
model had built and verified.
The worked example
Real Clay source, added, verified, and evaluated through Clay’s own tools:
type user = {name: string, age: int}
let ada: user = {name: "Ada", age: 36}
let n = ada.name
let older = {...ada, age: 37}n evaluates to "Ada"; older.age evaluates to 37, with every field but
the one named in the spread carried over unchanged.
A few things worth calling out about the syntax:
type name = {field: Type, ...}declares a record. Clay always emits it asstructuralunder the hood — never a process-generated identifier — the same permanent, deliberate choice abilities made for the same reason: it’s the only form that’s actually a pure function of its text.{field: value, ...}constructs a value of whichever record type the fields match. Fields can be written in any order; Clay reorders them to the declaration’s own order before it reaches Unison. With an annotation (let ada: user = {...}, a function argument, anywhere the expected type is already known), the field set is checked directly against it. Without one, Clay looks for the one declared record type whose exact field-name-and-type set matches — genuinely ambiguous only if two DIFFERENT record types happen to declare identical field sets, which (being structural) means they’re not actually different types at all. If nothing matches unambiguously, Clay asks for an annotation rather than guess.expr.fieldreads a field. This is real, new syntax — there is no general dot access in Clay otherwise (an ability’sConsole.print(...)is a separate, narrower call form, not the same mechanism).{...expr, field: value, ...}builds a new record from an existing one with the named fields replaced, leaving everything else as it was. There is no.field.set(...)or similar method-call syntax — the spread is the only way to change a field, by design; a record’s generated accessor and setter are Unison-side implementation details the spread lowers to, not something Clay exposes as callable syntax in its own right.
Path assignment: acc.address.city = "London"
On a var, assigning through a path of fields rebuilds the records on the path and rebinds the var
(built 2026-10-09). It is pure sugar for the update-spread, so it hashes exactly like the spread you would
have written by hand:
type address = {city: string, zip: int}
type person = {name: string, age: int, address: address}
let moved = (p: person): person => {
var q: person = p
q.address.city = "London" // q = {...q, address: {...q.address, city: "London"}}
q.age = 37 // q = {...q, age: 37}
q
}The root of the path must be a var (an assigned let is an error), and a path may end in a dictionary key
(q.tags["k"] = 1, which is Dict.set on that field). Like .field reading, the record type must be
statically known at that point: inside a while or for body a carried var has no annotation, so write the
update as a helper function there (q = bump(q, i)).
There is no .modify: q.age = q.age + 1 is the same thing, spelled with the pieces you already have.
What’s still missing
- A record type may be named in a function’s parameter or return type and in a
letannotation (built 2026-10-03), including generic records with one to N type parameters. - Nested records work (a record field whose type is another record, read with
a.b.c, updated with a nested spread or the path assignment above). - Two records of the same shape (the same field types in the same canonical order, such as
{x: int}and{y: int}) are one type to Unison and to Clay’s hasher. Clay keeps each declaration’s own field names, so both print back as written; a literal that fits more than one of them needs an annotation. For types that must stay distinct, declare themunique type(see Variants & switch). - Structural matching across unrelated record types (“anything with a
namefield”, matching by shape across two record declarations with no relationship to each other) isn’t supported — a record literal must match one specific declared type’s exact field set. .modify— Unison auto-generates a third helper per field; Clay does not expose it (decided: path assignmentq.f = g(q.f)covers it without a new form).- Dictionaries (
dict<T>,d["k"]returningoption<T>) are a separate, built feature (see Lists, dictionaries & options) — a record is the right choice when the key set is fixed and known. A record field may itself be adict<T>. - A record’s name shares the same namespace as every term and ability in a branch, and — unlike an ability’s name, which is always capitalized and so can never collide with a term — a record’s name is an ordinary identifier, so a term and a record could theoretically collide if given the same name. Not hit in practice yet, worth deciding before records grow further.