Skip to content

types: a module's interface carries the records and opaque types it references - #78

Merged
simontreanor merged 3 commits into
mainfrom
transitive-exports
Aug 29, 2026
Merged

types: a module's interface carries the records and opaque types it references#78
simontreanor merged 3 commits into
mainfrom
transitive-exports

Conversation

@simontreanor

@simontreanor simontreanor commented Aug 29, 2026

Copy link
Copy Markdown
Owner

What

ModuleExports held only what a module declares, not what its exports refer to, and two bugs fell out of that gap:

How

The interface now closes over what it references, the same treatment the scheme side got in #26.

  • extern type exports as name + arity (ExportedOpaque) and registers in a consumer under its bare identity name and a qualified key, exactly like a sum type, clash check included.

  • Records and opaque types mentioned by exported schemes, constructor schemes, or exported record fields are pulled from the imports' interfaces, tagged with the module that declares them, and re-exported (close_over_references). Newly pulled records are walked in turn, so a chain of any depth crosses, and a seen set terminates the walk on mutually referencing records. Each import's interface is itself closed, so one pull per hop reaches everything.

  • A carried type registers under its bare identity name only: it can be named, unified, and field-accessed. Constructing it, pattern-matching it, or writing the qualified spelling still requires importing the declaring module directly. By-name field lookup treats carried records as a fallback tier: records declared here or imported directly decide first, and a carried record owns a field only when no record in that tier declares it, so a dependency two hops away can never make a working access ambiguous. Two carried records that alone declare a field still tie, and the ambiguity message names them.

  • A carried name already taken (by a local type or a direct import) is skipped silently, since the consumer never wrote that name: local and direct declarations win, and the same record arriving along two import paths (a diamond) is recognized by its declaring module and admitted once. A genuinely shadowed record's fields feed the diagnostics instead:

    unknown record field `cSize`; the record `Config` in module `Inner` declares
    this field, but another type named `Config` is already in scope here, so that
    record is hidden
    

    and constructing or matching a carried record bare is refused with the fix spelled out: the record `Config` is declared in module `Inner`; import `Inner` to construct its values here (the pattern site says "to match"). A name that is no record at all keeps "not a record type".

  • The lowering side keeps up: a record update in a consumer that never imports the declaring module reconstructs via that module's class (inner.Config(...), with import inner hoisted). ModuleExports::carried_records feeds the project driver's per-module ImportContext, and record_class_name accepts the carried modules.

  • A genuinely unknown qualified type still reports one clean unknown type error, covered by a test.

DESIGN.md §6.1 and INTERNALS.md describe the closure and the name rules; the stale ExportedType doc comment about records/measures/externs is corrected.

Tests

New in tests/project.rs: both issue repros end to end (e2e_an_extern_type_can_be_named_across_the_module_boundary, e2e_a_transitive_record_field_is_accessible), updates through the boundary, depth 3, a record cycle, the direct-over-carried field precedence (including the breaking program from review, end to end), carried-vs-carried ambiguity, the shadowed-name hint, the diamond, direct-plus-carrier coexistence, construction refusal without a direct import (qualified, and bare with the construct/match messages), bare naming of a carried opaque type, the opaque clash check, and the clean unknown-type error.

cargo test, cargo clippy --all-targets, and cargo fmt --check are clean.

Closes #75
Closes #72

…eferences

ModuleExports held only what a module declares, so anything its exports
referred to fell off at the next boundary: an extern type could not be
named across a module at all (#72, there was no slot for it), and a
record reached only through another module's exports had invisible
fields (#75, the field registry crossed one hop while the type crossed
two).

Both get the same treatment the scheme side got in #26: the interface
now closes over what it references. extern type declarations export as
name + arity and register in a consumer like any imported type, clash
check included. Records and opaque types mentioned by exported schemes,
constructors, or record fields are pulled from the imports' interfaces,
tagged with their declaring module, and re-exported; the walk follows
newly pulled records and terminates on cycles.

A carried type registers under its bare identity name only, so it can
be named, unified, and field-accessed, while construction and patterns
still require importing the declaring module. A taken bare name skips
the carried entry silently (local and direct declarations win; the same
record along two paths is admitted once), and a shadowed record's
fields feed the unknown-field diagnostic, which now names the record
and the module that declares it. Record updates in a consumer that
never imports the declaring module reconstruct via that module's class,
with its import hoisted.

Closes #75
Closes #72
Constructing or pattern-matching a transitively carried record bare
said "not a record type", which the surrounding program visibly
contradicts: the same name reads fields and updates fine. The two
rejection sites now use the carried entry's declaring module
(Decls::carried_record_home) to state the real situation and the fix:
"the record `Config` is declared in module `Inner`; import `Inner`
to construct its values here" (the pattern site says "to match").
A name that is no record at all keeps the old message, and qualified
tags behave as before.
A carried record used to join the field-owner multimap as a peer, so a
dependency two hops away could make a working access ambiguous: a
consumer with a local record declaring size stopped compiling when a
module it never imports gained a record with the same field. By-name
field lookup now applies the precedence the bare-name rule already
established: records declared here or imported directly form the
deciding tier, and a carried record owns a field only when no record in
that tier declares it (field_owner_tier, consulted by record_of_field
and the pending-field fallback for a base still unknown at the end of
its binding). Ambiguity is reported within whichever tier decided, so
two carried records that alone declare a field still tie and the
message names them. Every resolution that worked before is unchanged,
and the issue 75 repro still resolves, since its field has a single
owner.
@simontreanor
simontreanor merged commit 024e6fb into main Aug 29, 2026
16 checks passed
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.

A record reached only through another module's exports has invisible fields An extern type cannot be named across a module boundary

1 participant