Skip to content

types: let a type declaration name an imported type - #36

Merged
simontreanor merged 1 commit into
mainfrom
fix/imported-types-in-declarations
Jul 31, 2026
Merged

types: let a type declaration name an imported type#36
simontreanor merged 1 commit into
mainfrom
fix/imported-types-in-declarations

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

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 though Placed is exactly the bare identity name an imported record registers under, and Shapes.Placed did 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_decls resolves record fields and ADT variants against type_arity in its pass 2, while imported names were merged only after build_decls returned. Nothing imported existed yet when local type bodies resolved.

The fix splits the type merge in two. 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 and ambiguity reporting is untouched.

Type positions now take a qualifier too, one dot deep with an uppercase segment either side, matching the Module.member rule for values. Each imported type registers under 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, so Shapes.Placed, a bare Placed, and Placed in its home module are one Ty::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 Holder without importing Shapes can hold and pass the item value but cannot access its fields, because only Shapes brings 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 an extern signature; 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.

`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.
@simontreanor
simontreanor merged commit 62b9dfa into main Jul 31, 2026
11 checks passed
@simontreanor
simontreanor deleted the fix/imported-types-in-declarations 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.
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