types: let a type declaration name an imported type - #36
Merged
Conversation
`type Holder = { item: Placed }` reported an unknown type even though
`Placed` is exactly the bare identity name an imported record registers
under, and the qualified `Shapes.Placed` did not parse at all. The cause was
phase ordering: `build_decls` resolves every record field and ADT variant
against `type_arity` in its pass 2, while imported names were merged only
after `build_decls` had returned, so nothing imported existed yet when local
type bodies resolved. Any record mentioning another module's type therefore
had to live in that module's file, which is the one finding from the
dogfooded game that changed a program's architecture rather than its
phrasing: a board module and a rules module had to merge into one 340-line
file.
The type merge now runs in two halves. `merge_imported_type_names` registers
names and arities from inside `build_decls`, between pass 1 (local names,
which still win a clash) and pass 2 (local bodies, which can now see
imported names), and returns the pairs it accepted; `merge_imported_types`
fills in constructor sets and record field registries for those pairs
afterwards, unchanged, so local records still lead the field multimap.
Type positions also take a module qualifier now, one dot deep with an
uppercase segment either side, matching the `Module.member` rule for values.
Each imported type registers under both its bare identity name and a
qualified key at the same arity; `resolve` validates whichever was written
and folds the qualified form back to the identity name, so `Shapes.Placed`,
a bare `Placed`, and `Placed` inside its home module are one `Ty::Con` and
unify. A qualifier that names a module exporting no such type is an unknown
type rather than a silent fallback to the bare name.
Not covered, and documented as such: transitive naming. A third module
importing `Holder` without importing `Shapes` can hold and pass the `item`
value but cannot access its fields, since only `Shapes` brings the field
registry into scope. That is a limit on use, not a soundness hole.
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.
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 #1 (see #34) — the one that changed a program's architecture rather than its phrasing.
type Holder = { item: Placed }reported an unknown type, even thoughPlacedis exactly the bare identity name an imported record registers under, andShapes.Placeddid not parse at all. So every record mentioning another module's type had to live in that module's file: in the dogfooded game a board module and a rules module were forced into one 340-line engine.Root cause is phase ordering, not the parser.
build_declsresolves record fields and ADT variants againsttype_arityin its pass 2, while imported names were merged only afterbuild_declsreturned. Nothing imported existed yet when local type bodies resolved.The fix splits the type merge in two.
merge_imported_type_namesregisters names and arities from insidebuild_decls, between pass 1 (local names, which still win a clash) and pass 2 (local bodies, which can now see imported names), and returns the pairs it accepted.merge_imported_typesfills in constructor sets and record field registries for those pairs afterwards, unchanged, so local records still lead the field multimap and ambiguity reporting is untouched.Type positions now take a qualifier too, one dot deep with an uppercase segment either side, matching the
Module.memberrule for values. Each imported type registers under its bare identity name and a qualified key at the same arity;resolvevalidates whichever was written and folds the qualified form back to the identity, soShapes.Placed, a barePlaced, andPlacedin its home module are oneTy::Con. A qualifier naming a module that exports no such type is an unknown type, not a silent fallback.Known limit, documented in DESIGN §6.1: transitive naming. A third module importing
Holderwithout importingShapescan hold and pass theitemvalue but cannot access its fields, because onlyShapesbrings the field registry into scope. A limit on use, not a soundness hole.Follow-up worth tracking separately: now that a type name can be written in another module's file, in-file-only rename of a type can leave stale references behind. Cross-file type nav did not exist because qualified type syntax did not exist; it does now.
Tests: three in
tests/project.rs(an e2e round trip using both spellings in a record field and an ADT variant; the two spellings unifying through anexternsignature; an unknown qualifier reported), plus four roundtrip cases covering qualified names in a field, a variant payload, and an extern signature with and without type arguments. Full suite, clippy and fmt clean.