Skip to content

Anonymous Records - #6143

Open
ChrisPenner wants to merge 96 commits into
trunkfrom
cp/record-prototype
Open

ChrisPenner wants to merge 96 commits into
trunkfrom
cp/record-prototype

Conversation

@ChrisPenner

@ChrisPenner ChrisPenner commented Jan 26, 2026 •

Copy link
Copy Markdown
Member

Adds anonymous record types to Unison.

Anonymous records allow you to create types and values which work effectively liked named tuples.

E.g.

manhattanDistance : {x: Int, y: Int} -> {x: Int, y: Int} -> Nat
manhattanDistance = cases
  {x: x1, y: y1}, {x: x2, y: y2} -> let
    dx = abs (x1 - x2)
    dy = abs (y1 - y2)
    dx + dy

> manhattanDistance {x: +1, y: +2} {x: +3, y: +4}

You can also embed these types in other types as usual, e.g. you could instead define this using a Point type,
NOTE the difference between Unison's current ADT-based records type Point = {x: Int, y: Int}; and the new form: type Point = Point {x: Int, y: Int}.

The former is an ADT with two fields, and generated accessors. The latter is an ADT with a single field which is an anonymous record.

type Point = Point {x: Int, y: Int}

manhattanDistance : Point -> Point -> Nat
manhattanDistance = cases
  Point {x: x1, y: y1}, Point {x: x2, y: y2} -> let
    dx = abs (x1 - x2)
    dy = abs (y1 - y2)
    dx + dy

> manhattanDistance (Point {x: +1, y: +2}) (Point {x: +3, y: +4})

You can access record fields with rec@field (no spaces)

person = {address: {street: "123 Main St", city: "Anytown", state: "CA", zip: "12345"}}

> person@address@city
          ⧩
          "Anytown"

This is NOT a "Row Types" implementation, however it does include rudimentary sub-typing, i.e. you can type {name: Text | ... } to accept ANY record which has a text name field; however once the type information for the fields in ... is lost it cannot be recovered.

See the new-records.md transcript for a more comprehensive exploration.

@ChrisPenner
ChrisPenner force-pushed the cp/record-prototype branch 2 times, most recently from a44fd6c to 6518967 Compare February 11, 2026 19:33
Comment thread unison-runtime/src/Unison/Runtime/Machine.hs Outdated
Comment thread unison-runtime/src/Unison/Runtime/TypeTags.hs Outdated
@ChrisPenner ChrisPenner changed the title [WIP] Records prototype Anonymous Records Sep 28, 2026
@ChrisPenner
ChrisPenner marked this pull request as ready for review September 28, 2026 20:37
Comment thread unison-runtime/src/Unison/Runtime/Machine.hs Outdated
ChrisPenner and others added 9 commits September 28, 2026 15:17
FnTag.word2tag was missing its FRecT case and VaTag.word2tag was missing
RecordT, so any serialized code containing a record constructor, or any
serialized Value containing a record, failed to load. This made every
compiled binary containing a record literal unloadable:

  $ unison run.compiled mainbin.uc
    unknown FnTag word: 7 (585 bytes remaining)

Also guard the RecordT branch of getValue on the payload version, matching
its DataT and ContT neighbours, and hash an explicit field count for record
terms and record patterns. Every token in those flat name/value runs happens
to be self-delimiting today, so this is insurance rather than a fix for a
demonstrated collision - but record hashes are permanent, and the count costs
one token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
checkPattern's record case called applyM on each field's unification variable
and threw the results away, so the recursive calls got the raw existential.
subtype doesn't apply the context to its arguments itself, so when an outer
constraint had already solved that variable, solve found a conflicting prior
solution and reported a bare TypeMismatch. Nested record patterns therefore
never typechecked at all:

  g : { a: { b: Nat } } -> Nat
  g = cases { a: { b: b } } -> b
  --> I found a value of type: {b: Nat}
      where I expected to find: {b: 𝕩| ...}

Binding the applyM results fixes nesting to arbitrary depth, and also fixes
the general case where unifying one field refines another field's type.

subtype's record case also had MissingRecordField and UnexpectedRecordField
swapped: This means the field is present only in the subtype, which is an
extra field, and That means the subtype is missing a required one. The
transcript stanza commented "nice error if we have additional unexpected
fields" was rendering as "I expected this record to have the field address"
about a record that plainly had address. The Cause doc comments disagreed with
how Extractor/TypeError/PrintError bind the arguments, so correct those too.

Also:
- equate0 now requires matching FieldBehavior, so an open record no longer
  equates with a closed one in an invariant position, and reports the right
  constructor per direction.
- instantiateR's record case recursed into instantiateL; the two agree for
  monotype fields but not for arrow- or forall-typed ones.
- RecordPatternMatchOnNonRecordType was never constructed. Raise it when the
  scrutinee is already known not to be a record, and promote it and
  PatternMatchedMissingField to real TypeErrors that point at the pattern
  rather than falling through to the raw-cause bucket.
- renderPattern passed an empty variable supply to prettyPattern, so it
  crashed on any pattern containing a variable. Latent for its existing
  callers, which render coverage-checker output using Unbound.
- Drop the resolved TODOs on discardCovariant's record case and checkWanted.
- Normalize record type printing to {a: Nat | ...} in both printers.

structural-records.md was a stale pre-idempotent transcript whose committed
output recorded a crash at a Context.hs line that no longer exists; its two
unique cases (list unification, mismatched field types) move into
new-records.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
Before this, only fully irrefutable record patterns worked. Any refutable
subpattern crashed the coverage checker outright:

  lit = cases
    { x: 0 } -> "zero"
    _ -> "other"
  --> error "expected Pattern-5 to be in UFMap"

Two things were missing. The coverage checker had no record constraint at all:
Literal.PosRecordLiteral carried no field types, addLiteral declared the
*record* variable -- which is the scrutinee, already declared -- rather than
the field variables, and addConstraint's handler was a stub that always
succeeded. So records participated in neither redundancy nor exhaustiveness
reasoning, and declVar had been weakened from alter to insert to hide the
resulting double declaration.

Records are now modelled as what they structurally are: products with exactly
one constructor. A new Vc'Record constraint holds a variable per matched field;
addConstraint merges a new positive constraint with any existing one by
equating shared fields, mirroring PosCon; and a new RecordType case in
EnumeratedConstructors gives withConstructors the field types so instantiation
walks into them. declVar is restored to alter with its guard.

Falling out of that:
- A record with an uninhabited field is now itself uninhabited, so
  Either { x: Void } Nat no longer demands a Left case. The transcript
  documented this as known-wrong.
- Uncovered-pattern suggestions name fields: {age: _} rather than _, and
  nested ones reach {a: {b: None}} rather than stopping at {a: _}.

Fixing coverage then exposed the next layer, unreachable until now: in
Runtime.Pattern, PType required record schemas to be equal, so two cases
matching different field subsets hit "inconsistent pattern matching types",
and decomposeRecPattern emitted each row's own fields positionally, so rows
of differing length misaligned when buildMatrix transposed them. Rows are now
decomposed against the union of the fields matched anywhere in the column,
one slot per field. The filler has to be a Var rather than Unbound: by that
point prepareAs has rewritten every user-written _ as a Var, so an Unbound
head is the marker chooseVars uses to skip wildcard-decomposed rows.

Also drops the dead RecordSpec type and fixes prettyPmGrd, which rendered its
field map through Show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
A record naming the same field twice was accepted and silently kept the last
binding, because all three record positions build their field map with
Map.fromList:

  > dupLit = { a: 1, a: 2 }
  + dupLit : {a: Nat}
  > dupLit
    {a: 2}

Reject it in record literals, record types, and record patterns, pointing at
both the first and the duplicate occurrence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
A record value was an `EnumMap FieldRef Val`, keyed on an interning id handed
out in first-seen order as the emitter met each field name. `EnumMap` iterates
in key order, so comparison weighed fields in *intern* order:

  Universal.compare {a: 2, z: 1} {a: 1, z: 2}

returned +1 or -1 depending on whether `a` or `z` had been compiled first,
which is unrelated compilation history. Since Universal.compare underpins Map
and Set, two codebases holding the same definitions could iterate or dedupe
differently. The cross-schema branch was worse: `compare rr1 rr2` on interned
schema ids, with no semantic meaning at all.

A record is now `GRecord !RecordShape !(Vector Val)`, where slot i holds field
i of the shape in ascending field-name order. `RecordShape` carries the field
names and is reachable from the value, which is what lets `Eq` and
`compareClosure` -- both pure, with no access to the code cache -- order
records by name. Ordering two records of *different* shapes is reachable: they
can be compared once wrapped in `Any`, which erases their types. Those compare
their sorted field-name lists, so `Any {a: 1} < Any {b: 1}` and a shape whose
fields are a prefix of another sorts first.

The shape also carries each field's slot, which is what `RecUnpack` needs. It
can't bake slots into the instruction: one pattern matches records of several
shapes, and the same field sits at a different slot in each -- `getA` below
finds `a` at slot 0, 1 and 2.

  getA : { a: Nat | ... } -> Nat
  getA = cases { a: a } -> a
  getA { a: 1, b: 2 }; getA { z: 9, a: 7 }; getA { x: 1, y: 2, a: 42, zz: 3 }

Because the names now travel with the value, reflectValue is a few lines rather
than a plumbing exercise, so `Value.value` on a record no longer aborts the
process. Verified end to end: reflect, serialize, deserialize, reify, read the
fields back, using unsorted source order so a misordering would show up.

Also in this change:
- cacheAdd0 derives the record schemas it needs from the groups it is given, via
  a new ANF.groupRecordSchemas. cacheAdd previously passed mempty, so
  Code.cache_ on record-bearing code hit "unknown record schema"; codeValidate
  threw outright on `error "TODO: recordRefsFromCode"`. Both now work. This also
  lets the Tm.recordSchemas plumbing through Interface be removed -- it only saw
  surface terms, never patterns.
- codeValidate ran `evaluate` on the State action rather than its result, so it
  forced nothing since emitCombs became stateful.
- Field names no longer need pre-registering in cacheAdd0; building a shape
  during emit interns them, so RefNums.recField goes away and registration has
  one mechanism instead of two.
- Deletes the abandoned FieldTag representation: the newtype, its unused
  get/put pair in Serialize, the underscore-prefixed pair in MCode/Serialize,
  fieldNameLookup, and the unused MCode.FieldTags.
- recordRefLookup reports through internalBug rather than a raw error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
Four placeholder lookups sat in Main.hs (three copies, one shadowing another
in the same expression) and OutputMessages.hs:

  let rsLookup rn = "<unknown-field-" <> tShow rn <> ">"

so any runtime error whose decompiled value contained a record printed
<unknown-field-7> in place of the field name. OutputMessages has no runtime
handle, so supplying the real mapping there would have meant carrying it in
the error payload.

Now that a record value carries its shape, and the shape carries the field
names, decompile reads them straight off the value. That makes the whole
FieldRef -> Text parameter dead, so this removes it from decompile,
decompileForeign, decompileCtx, prettyError and prettyRuntimeExn rather than
threading anything new through.

  > boom = bug { name: "Alice", age: 30 }
    I have encountered a call to builtin.bug with the following value:
      {age: 30, name: "Alice"}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
Reading a field inline required a match or a destructuring bind. `r@x` now
reads the `x` field of the record `r`. It binds tighter than application, so
`f r@x` is `f (r@x)`, and it chains: `r@a@b@c`.

`@` was chosen over `.` deliberately. `r.x` lexes as a single wordy identifier,
indistinguishable from a qualified name, so it would have needed a
resolution-order fallback -- rewrite only when the whole name resolves to
nothing but some prefix does -- which makes the meaning of `r.x` depend on what
is in scope. It would also have been identifier-only: `(mk 1).a` does not fail
today, it silently parses as `mk 1 .a`, applying `mk` to the absolute name `.a`.
Teaching the lexer otherwise means changing how `.` is tokenized.

`@` needs none of that. It already lexes as its own token, because as-patterns
depend on it, so `r@x` is three tokens and the left side can be any expression:
`(mkRec 5)@v` and `{v: 9}@v` both work. The `@` must be adjacent to both sides,
which keeps it from reading as an infix operator and keeps `x@(Some n)`
as-patterns working unchanged.

There is no new term form. `base@field` desugars to a single-field record
match, which typechecks to `{field: t | ...} -> t` through the existing pattern
machinery and compiles to the existing RecUnpack instruction -- so no hash,
serialization, ANF or runtime changes. TermPrinter recognizes that shape and
prints it back as `base@field`, parenthesizing the base when needed, so `view`
shows the syntax that was written.

The generated binder is named `field` rather than `_field`: a leading underscore
makes it a wildcard when the printed form is re-parsed, which silently dropped
the binding and broke the round trip.

One limitation: a function that is *exactly* one projection still prints as
`cases {a: field} -> field` rather than `f r = r@a`. Preferring the projection
form there means the printer has to invent a name for the lambda variable, and
`cases {x: x} -> x` -- the same term, differing only in that name -- then
printed as `f a8qfpevfug1 = a8qfpevfug1@x`. The `cases` rendering is the better
of the two, and it round-trips.

Also reworded two record errors that said "pattern" for what the user wrote as
a projection, and adds records, record patterns, destructuring binds and
projections to the round-trip corpus -- the namespaces come back identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
`@rewrite case` works by converting a match case's pattern into a term, using
`ABT.rewriteExpression` to do the structural match, then converting the result
back to a pattern. Both directions raised `error "TODO"` on records, so a rule
whose LHS was a record pattern crashed -- and so did any `case` rule at all
applied to a definition that merely contained a record pattern somewhere.

Both directions are the obvious symmetric pair: a record pattern becomes a
record literal term and back. The subtlety is variable order. `intop` assigns
the case's abs-chain variables in traversal order, and `matchCaseFromTerm`
recovers them with `ABT.allVars`, so the two have to agree. For a record both
go through the `Map Text` instances -- `traverse` on the way in, `Foldable` on
the way out -- which visit fields in ascending key order, the same order the
parser binds them in. That falls out for free, and a rule that swaps two field
subpatterns rewrites `{x: p, y: q} -> p + q * 2` to `{x: q, y: p} -> p + q * 2`,
carrying each binding to the field it was swapped onto.

Also drops the `Apps' (Record' _) _args` case from `toPattern`. Unlike a
constructor, a record literal is never applied to arguments, so that shape
isn't a term that could have been a pattern; it now falls through to `Nothing`
like any other non-pattern.

Note that a bare `_` in a rule LHS still doesn't work as a wildcard, in a
record pattern or anywhere else -- `case Some _ ==> None` fails the same way.
`_`-prefixed names do work, and that's what the transcript uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
Fourteen files conflicted. Most were two features appending to the same list and
both sides just belong -- new `Error`/`TypeError`/`Cause` constructors, LSP
diagnostic ranges, import lines, `matchCallingError` guards, `VarConstraints`
arms.

Five were real collisions: trunk's Bytes patterns and variadic FFI claimed the
same tag numbers the records work had taken, in five separate tag spaces. Trunk
keeps its numbers in every case and records move up:

  Hashing.V2.Pattern    PatternRecord    14 -> 15   (vs PatternBytes)
  Merge.Synhash         RecordLiteral    17 -> 18   (vs Pattern.Bytes)
  Sqlite.Serialization  PRecord          14 -> 15   (vs PBytes)
  ANF.Serialize.Tags    MRecT             7 -> 8    (vs MBytesT)
  MCode.Serialize       RecPackT/Unpack  22,23 -> 24,25 (vs NewForeignPtr/AddFinalizer)
  ANF.MurmurHash        MatchRec          8 -> 9    (vs MatchBytes)

The first three change record hashes and make codebases written by earlier
builds of this branch unreadable. That's the usual cost of an unreleased tag
space and nothing pins those hashes, but it is why the numbers moved rather than
trunk's.

Two resolutions are not just "take both":

The V4 code codec now refuses records, matching what trunk did for Bytes
patterns. This branch had taught `getBranches` to read `MRecT` but never taught
`putBranches` to write it, so nothing could produce a V4 payload containing a
record and the reader was unreachable. The catch-all already rejected records
with "malformed intermediate term"; both directions now say what is actually
wrong. Completing V4 record support is a separate job.

Restored a comment in `FileAnalysis` that this branch had deleted when inserting
the record diagnostic cases. It documents the `TypeError.Other` fall-through and
still applies.

Verified: builds clean, 392 transcripts pass, round-trip namespaces identical
with `main.output.md` byte-identical, ormolu clean on all fourteen files. Tag
numbers audited for duplicates across every family both sides touched, including
the ones that merged without conflicting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
@ChrisPenner

ChrisPenner commented Sep 29, 2026 •

Copy link
Copy Markdown
Member Author

I've had Claude finish of the remaining bits and bugs; I gave it a review, but it would definitely be prudent to leave it on trunk for a bit before we make a release with it, it's a huge change.

For clarity, the handwritten stuff is everything on cp/record-prototype-human, the ai stuff is everything since then (Claude labels its own commits)

@ChrisPenner
ChrisPenner requested a review from a team as a code owner September 29, 2026 20:09
@ChrisPenner
ChrisPenner changed the base branch from trunk to wip/remove-cycle-length-draft September 29, 2026 20:11
@ChrisPenner
ChrisPenner changed the base branch from wip/remove-cycle-length-draft to trunk September 29, 2026 20:11
ChrisPenner and others added 3 commits September 29, 2026 13:58
The runtime test suite hasn't compiled since the records work began. Nothing
caught it because this branch was only ever verified with transcripts, and
`stack build --test` was never run.

`SCache` gained three fields -- `frs`, `rsLookup`, and `rfm` -- and
`genStoredCache` was never extended, so the `StoredCache` round-trip property
test failed to typecheck. The misalignment had also pushed `mempty`, the "we
don't yet generate supergroups" placeholder, off the supergroup map and onto the
new `Word64`, where it still typechecked as an empty generator. It's back where
its comment says it belongs.

BiMaps are generated through `fromMap` on purpose. The codec stores only the
forward map and re-derives the backward one, so a BiMap built any other way can
carry a backward map that doesn't follow from its forward map -- and then the
property fails on BiMap's invariants instead of on the codec.

Two more breakages fell out of the same blind spot:

`RefNums` gained `recNum`, and `emitCombs` now returns `State
RecordFieldMappings`, so `testLift` needed both a fourth stub and somewhere to
run the state. Note the stubs stay hand-written rather than using `emptyRNs`:
every one of that value's lookups throws, and `emitCombs` calls them. Also worth
knowing that this test was previously asserting nothing -- `cs` was an unapplied
`State` function, so the bang pattern forced a closure. Making it compile is what
made it start testing.

`-Werror=incomplete-patterns` wanted `MatchRec` and `FRec` in the ANF test's
denormalizer. Implemented rather than stubbed: a record match denormalizes to one
irrefutable `RecordLiteral` case, and `FRec` is the only `Func` that isn't
applied to its arguments -- they are its field values, in ascending field-name
order. Both are currently unexercised, because no `testANF` case builds a record;
they are written from the field-ordering invariant, not from a passing test.

Adds `emptyRecordFieldMappings` next to `emptyRNs` so the test doesn't hardcode
`FieldRef 0`; `Machine/Types.hs` now uses it in place of its local `initRFM`.

Results: 74 + 157 + 296 + 2 + 47 pass in core1, syntax, parser-typechecker,
share-api and cli, and 304 pass in runtime. 392 transcripts still pass and
ormolu is clean.

One runtime test still fails, on trunk rather than here:
`ffi.dynamic.fixed prefix is not promoted` gets `Left BadInit` where it expects
`Right ()`. `BadInit` means the system libffi's `ffi_prep_cif_var` rejected an
unpromoted fixed prefix. The test and the code under it arrived with the
variadic-FFI merge and neither exists at this branch's merge base, and none of
the 70 files this branch touches is an FFI file, so it looks like a
platform-specific failure on macOS arm64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
`RecordLiteral` was missing from the hand-written `Eq (Pattern loc)` instance, so
two structurally identical record patterns fell through to `_ == _ = False` and
compared unequal. Fixed in `Unison.Pattern`, which is the instance that gets
used, and in `Unison.Hashing.V2.Pattern`, which has the same omission.

The visible symptom was `sfind`:

    scratch/main> sfind litRule
    😶 I couldn't find any matches.

against a definition that literally contains the rule's `{x: 0}` pattern.
`sfind` reaches pattern equality through `containsCaseTerm` -> `hasSubpattern`.
`rewrite` doesn't, which is why the `@rewrite case` work passed its tests over
this bug: it compares patterns after converting them to terms, and the term `Eq`
already had its `Record` case. A transcript case now covers the `sfind` path.

Found by adding the record cases to `testANF` that the last commit left owing.
Two of the four failed with expected and actual printing identically -- the tell
for an `Eq` that ignores a constructor. So `MatchRec` and `FRec` in the ANF
test's denormalizer, added blind last commit, were right; what they were being
compared against was not. Those four cases also pin the field-ordering claim:
`{b: x, a: y}` lays its fields out in ascending name order, not source order.

Note the derived `Ord (Pattern loc)` still compares locations while this `Eq`
ignores them. That inconsistency predates records and is left alone.

Verified: 392 transcripts pass, round trip unchanged and namespaces identical,
and core1/syntax/parser-typechecker/share-api/cli all pass. Runtime is 304 passed
with 9 `anf.denormalize` cases, up from 5, and still the one pre-existing trunk
failure in `ffi.dynamic`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSRrHY56U2R3UbZrj6Zacb
@aryairani

aryairani commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

I wonder why libffi failure is only showing up now?

@aryairani aryairani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forget, was there some really good reason(s) not to overload . (namespace lookups, floats) for field accesses instead of overloading @ (bound patterns)

@pchiusano

pchiusano commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

I forget, was there some really good reason(s) not to overload . (namespace lookups, floats) for field accesses instead of overloading @ (bound patterns)

Is Foo.bar a field accessor on the value Foo or a qualified name? No way to know at parse time.

Haskell sorta gets around this by not allowing uppercase identifiers other than types and data constructors, and requiring that data constructors and module names be uppercase.

So Foo.bar can't be a record access because that would imply Foo is a nullary data constructor, and nullary constructors don't have fields.

And foo.bar can't be a qualified name, because qualified names always start with an uppercase segment like List.map. So foo.bar has to be the expression foo . bar.

@ChrisPenner

ChrisPenner commented Oct 5, 2026 •

Copy link
Copy Markdown
Member Author

Yeah, pretty much all of what Paul just said, it would make a pretty big mess of the lexer, parser, and pretty-printer to use .
Also then we'd have to make sure it's always unambiguous, even though TDNR and record access are completely different operations; e.g. in rec -> rec.field; if your namespace has rec.field defined it's impossible to know what rec.field should refer to, and there's a bit of an explosion in things you need to check when you're doing nested access, e.g. is person.address.street an FQN, or is person.address an fqn and .street is record access? Or is person a record and .address.street the accessor?

The syntax is relatively easy to change to something else if we want, as long as that something else isn't . haha

@aryairani

aryairani commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

I approved it, we can always revisit syntax later. Go ahead and merge once CI is passing?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants