Skip to content

Fix record accessors for higher-rank field types#6239

Open
runarorama wants to merge 3 commits into
trunkfrom
fix/rank-n-record-accessors
Open

Fix record accessors for higher-rank field types#6239
runarorama wants to merge 3 commits into
trunkfrom
fix/rank-n-record-accessors

Conversation

@runarorama
Copy link
Copy Markdown
Contributor

@runarorama runarorama commented May 27, 2026

A record-style data declaration whose field has a rank-N type — e.g.

type Box = { unbox : forall a. a -> a }

failed to typecheck. The auto-generated getter cases Box f -> f has no type annotation, so synthesis infers Box -> a2 -> a1 (the inner forall instantiates to distinct existentials that can't be re-generalized in a purely inferred result), and the typechecker complains:

I found a value of type:  a2 -> a1
where I expected to find: a -> a

The setter and modifier hit the same issue from the other side (constructor application with a rank-N argument).

The fix is to give each auto-generated accessor an explicit type annotation built from the data declaration's tyvars and the field's type. Check-direction Skolemization handles the nested foralls correctly, so the same accessor body that previously failed inference now type-checks cleanly.

Each field's annotation is shaped as:

getter:  forall ddTyvars. T ddTyvars -> fieldType
setter:  forall ddTyvars. fieldType -> T ddTyvars -> T ddTyvars
modifier:
         forall ddTyvars. (fieldType -> fieldType)
                          -> T ddTyvars -> T ddTyvars

The field types come from the /resolved/ data declaration's constructor (which already has every type reference bound to the namespace) rather than the raw parsed types — otherwise an annotation built from a parsed field type referencing another type in the same file would surface as "I don't know about the type X" because that reference hasn't been resolved at accessor-generation time.

Fixes #498.

A record-style data declaration whose field has a rank-N type — e.g.

    type Box = { unbox : forall a. a -> a }

— failed to typecheck. The auto-generated getter
@\b -> case b of Box f -> f@ has no type annotation, so synthesis
infers @box -> a2 -> a1@ (the inner forall instantiates to
distinct existentials that can't be re-generalized in a purely
inferred result), and the typechecker complains:

    I found a value of type:  a2 -> a1
    where I expected to find: a -> a

The setter and modifier hit the same issue from the other side
(constructor application with a rank-N argument).

The fix is to give each auto-generated accessor an explicit type
annotation built from the data declaration's tyvars and the
field's type. Check-direction skolemization handles the nested
'forall's correctly, so the same accessor body that previously
failed inference now type-checks cleanly.

Each field's annotation is shaped as:

    getter:  forall ddTyvars. T ddTyvars -> fieldType
    setter:  forall ddTyvars. fieldType -> T ddTyvars -> T ddTyvars
    modifier:
             forall ddTyvars. (fieldType -> fieldType)
                              -> T ddTyvars -> T ddTyvars

The field types come from the /resolved/ data declaration's
constructor (which already has every type reference bound to the
namespace) rather than the raw parsed types — otherwise an
annotation built from a parsed field type referencing another
type in the same file would surface as
"I don't know about the type X" because that reference hasn't
been resolved at accessor-generation time.

Refs #498.
@runarorama runarorama requested review from dolio and pchiusano May 27, 2026 01:13
The rank-N accessor fix makes auto-generated setter/modifier types
consistent in their data-type tyvars: instead of an inferred
'Decoy a1 -> Decoy a' (distinct existentials at input vs output
positions), they now report 'Decoy a -> Decoy a'. Regenerated
'fix-pattern-capture' transcript accordingly.

Tests proof refreshed; transcripts proof left untouched (a flaky
timing-noise diff in mcp.md keeps causing local re-records to
record 'fail' even though the run itself succeeds — CI will
re-verify in a controlled environment).
Copy link
Copy Markdown
Member

@pchiusano pchiusano left a comment

Choose a reason for hiding this comment

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

I could have sworn I added a review of this but maybe I left it in drafts on my phone. Anyway, while I haven't reviewed the code in detail, this could use a transcript test to exercise, and it should illustrate being able to change the type of a field that is the sole field referencing some type variable(s). I remember this being the tricky part of writing out the fully general type.

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.

Record field accessors don't typecheck if the field is polymorphic

2 participants