types: resolve a field from the base's type when it is known - #37
Merged
Conversation
`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.
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dogfooding finding #2 (see #34).
Infer::infer_fieldcalledrecord_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.letterandCell.lettercould not coexist in readable form, and the dogfooded game prefixed its way out:cRow,cCol,cLetter, plus nineg-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.xtypes exactly as before,{ 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.nopewherep : Point→record `Point` has no field `nope`instead ofunknown 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 }whereybelongs to another record → the same precise message instead of a downstreamtype mismatchbetween 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.