Skip to content

types: resolve a field from the base's type when it is known - #37

Merged
simontreanor merged 1 commit into
mainfrom
fix/type-directed-field-resolution
Jul 31, 2026
Merged

types: resolve a field from the base's type when it is known#37
simontreanor merged 1 commit into
mainfrom
fix/type-directed-field-resolution

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Dogfooding finding #2 (see #34).

Infer::infer_field called record_of_field (the name-only multimap) and inferred the base one line later, so two records sharing a field name collided at every use site even where the base's type was already solved. Placed.letter and Cell.letter could not coexist in readable form, and the dogfooded game prefixed its way out: cRow, cCol, cLetter, plus nine g-prefixed fields on the game state. The error's own suggestion, pattern-matching to disambiguate, means destructuring at every use site, which is worse.

The fix is the ordering. Infer the base first; if its type is a known record, that record owns the field. The multimap stays as the fallback for a base that is still a unification variable, so:

  • fun p -> p.x types exactly as before,
  • ambiguity is reported only where the type genuinely is not known, and the message now says that rather than implying the field is unusable,
  • { p with … } gets the same treatment.

This is F#'s rule. DESIGN §8.3 needed rewriting rather than a footnote: it listed "type-directed access" as a rejected alternative to the multimap, on the grounds that it regresses fun p -> p.x. That reasoning is correct for type-directed access alone; layering it over the multimap costs nothing, since the type decides when known and the name resolves when not. The section now describes the two-step rule and records why the rejection applied to the standalone version.

Two error messages get sharper, and their tests move with them:

  • p.nope where p : Pointrecord `Point` has no field `nope` instead of unknown record field `nope`, with a did-you-mean for a near miss. The old message is still what an unknown base produces, and there is now a test for each.
  • { p with y = 2 } where y belongs to another record → the same precise message instead of a downstream type mismatch between two record types.

Tests: four new in tests/typecheck.rs (shared field resolved through a binding, a nested field, an update and a match; a known record missing the field; the did-you-mean; the unknown-base fallback). Full suite, clippy and fmt clean.

`infer_field` asked which record owned a field name *before* inferring the
base, one line later, so two records sharing a field name collided at every
use site even where the base's type was already solved. The workaround the
error suggested, pattern-matching to disambiguate, means destructuring at
every use site; the dogfooded game instead prefixed its way out, `cRow`,
`cCol`, `cLetter`, and nine `g`-prefixed fields on the game state, which is
a wart in an ML-family language.

The base is now inferred first, and a base whose type is a known record
picks that record outright. Field-name resolution through the multimap
stays as the fallback for a base that is still a unification variable, so
`fun p -> p.x` behaves exactly as before, ambiguity is reported only where
the type genuinely is not known, and the message says so. Same reordering
for `{ p with … }`. This is F#'s rule, and DESIGN §8.3 is updated: it
listed type-directed access as a *rejected alternative to* the multimap,
which is right, but layering the two costs nothing and that is what this
does.

Two error messages get sharper as a side effect, and their tests move with
them. A known record that lacks the field now reports "record `B` has no
field `x`" (with a did-you-mean for a near miss) rather than resolving to
whichever record happened to declare that name and failing downstream as a
mismatch between two record types, which is what `{ p with y = 2 }`
previously produced.
@simontreanor
simontreanor merged commit 8b19de8 into main Jul 31, 2026
11 checks passed
@simontreanor
simontreanor deleted the fix/type-directed-field-resolution branch July 31, 2026 11:21
@simontreanor simontreanor mentioned this pull request Jul 31, 2026
simontreanor added a commit that referenced this pull request Jul 31, 2026
Two dogfooding reports from real programs, and the standard-library sweep
they triggered.

Language:

* a `type` declaration can name an imported type, bare or module-qualified
  (#36) — the one gap that changed a program's architecture rather than its
  phrasing, forcing two modules into one file
* field access resolves from the base's type when it is known, so two
  records may share a field name without prefixes (#37)
* parameters destructure: tuples (#38), records (#40), and `_`
* a direct self tail call lowers to a loop, so an interactive turn loop no
  longer walks the stack (#39, #41)

Standard library — about 115 new members, taking every module to the F#
core set: List (#42), Seq (#44), Set and Map (#46), String (#47), Option
and Result (#48), then a member-by-member FSharp.Core audit (#51). Every
built-in member now carries a one-line description and its complexity in
hover and completion (#43, #49), enforced by tests.

Fixes:

* `pyfun run` on a single file gives the program its own stdin, so an
  interactive program is runnable by the command whose job is running
  programs (#35)
* a partially applied lambda closes over its argument instead of being
  wrapped, so `List.map ((+) 2)` emits `lambda b: 2 + b` (#52)
* every multi-argument callback's scheme put the effect variable on the
  wrong arrows, so `List.fold` could never accept an effectful folder (#51)
* `Seq.empty` lowered to a bare `iter()`, a TypeError (#51)

One source-incompatible change, which is why this is 0.4.0 and not 0.3.1:
a dotted `extern` target whose module prefix cannot be decided from the
text is now a compile error naming the `extern import` to add (#50).
`sys.stdout.flush` used to emit `import sys.stdout` and fail at runtime;
declaring `extern import sys` fixes it.
simontreanor added a commit that referenced this pull request Jul 31, 2026
Feedback from the other repo: #37 resolves a field when the base's type is
known *at the access*, which left two shapes still ambiguous. Testing them
splits the report in two.

`Map.tryFind` is not one of them: when the map's value type is pinned, the
payload resolves already. It fails only when the map itself arrives
unpinned, which is the same root cause as a bare lambda parameter.

The fixable shape is a base that a *later* statement pins down. `let l =
c.letter` followed by `let flag = c.blank` was rejected even though `blank`
determines `c`, because resolution happened at first sight rather than when
HM had the answer. Such an access is now recorded and settled once the
enclosing top-level binding is fully inferred.

The part that makes this sound is generalization: a variable a pending
obligation depends on stays monomorphic until it is settled. Block-level
`let`s generalize, so without that guard the deferred variable would be
generalized, each use would take its own copy, and the later resolution
would reach none of them — it would type-check and mean nothing. Tests
cover the three ways it must still fail: the field's type has to fit its
uses, the field has to exist on the record that wins, and a value nothing
pins anywhere is still an error.

That last message now names both ways out. It offered only the pattern form
(`case Cell { letter }:`), because the parameter form
(`fun (Cell { letter }) -> …`) did not exist when it was written — and that
is the one the report found reads better.
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.

1 participant