Wado

WEP 2026-07-29: Name namespaces as types

Context

Every naming defect found while migrating to structured fq names was the same mistake: a name from one namespace handed to a consumer that keys on another. The compiler answers at least five distinct questions with a String:

namespace example who keys on it
mangled identity core:prelude/list.wado/List<i32> func_map, emitted function names
declaration name List, MyArray, Wrapper<i32> impl headers, module scope, CM interface registry, go-to-definition
struct-list key bare head + qualified args the package's struct list
template key List monomorphize template registration
instance key List<i32> per-instantiation identity

Observed failures, all of this shape:

Structuring FqTypeName fixed the representation but not the failure mode, because every accessor still hands back a String and Receiver::head_key / decl_key are interchangeable to the type checker. Splitting them made the distinction expressible; nothing makes it enforced. Each of the bugs above was caught by a test, never by the compiler.

Decision

Make the namespace part of the type, so a name from one cannot reach a consumer that keys on another. Four changes, ordered so each is independently valuable and the cheapest one catches the most.

1. Namespaced name types

pub struct MangledName(String);
pub struct DeclName(String);
pub struct StructListKey(String);

No Deref<Target = str>, no AsRef<str>, no From<String>. They are minted only by the authority that knows the namespace — FqTypeName and TypeTable — and converted only through named methods that state why the change is sound. Display goes on DeclName alone, the one form meant for humans.

Registries then demand their own key type:

impl CmInterfaceRegistry {
    fn get_function(&self, key: &DeclPath) -> Option<&FunctionInfo>;
}
impl TraitEnv {
    fn impl_headers(&self, target: &DeclName) -> ...;
}

DeclPath is built as receiver + method, never with format!("{head}::{method}"), so the assembly sites disappear along with the chance to assemble from the wrong half.

This step alone makes five of the six failures above fail to compile.

2. LocalMethodName derives its rendered name

It stored struct_name: String alongside receiver and struct_type_args, with an unenforced invariant struct_name == receiver.mangle(struct_type_args). struct_name is a method over the two structural fields, and the illegal state is gone.

3. One encoding of "which type owns this method"

inherited_from_base: Option<TypeId>, struct_name and receiver were three encodings of the same fact, and the generic-newtype defect was them disagreeing. One replaces them:

enum MethodOwner {
    Own(TypeIdentity),
    Inherited { via: TypeIdentity, owner: TypeIdentity },
}

Naming reads owner. The newtype-override question reads the discriminant, rather than comparing a declaration name against an instantiated one — which is what made that guard never fire.

4. Declaration and instantiation separated in the type table

ResolvedType::Struct carries decl_name plus type_args: Vec<TypeId>; the rendered spelling is derived by TypeTable::struct_rendered_name. No fused name, so nothing to mistake.

Interning keeps the rendered spelling as its identity. Holding the argument ids as identity instead would mint two types where equivalent-but-distinct TypeIds meet — such ids demonstrably exist, which is why Monomorphizer::try_queue_function dedupes a blanket instance reached from two dispatch sites. What this step buys is that head and arguments are separately readable, not that identity changes.

make_monomorphized_struct carries a debug_assert_eq! that the caller's rendering matches struct_rendered_name, so a divergence surfaces in tests rather than as a wrong mangled name.

Converting a site is not a rename. The old name was the rendered spelling, so struct_rendered_name(decl_name, type_args) is the behaviour-preserving answer and decl_name is a behaviour change — where decl_name is right, the old code was wrong. A recurring shape, FqTypeName::declared(module_source, name) built from the rendered name, is the fusion written out longhand and collapses to fq_type_name(id).

The rules the split establishes

A struct registry is keyed by the rendering, not the declaration

struct_fields_map, struct_fields, struct_index, single_field and package.structs hold one entry per instantiation, so decl_name misses every one of them. TypeTable::struct_list_name owns this namespace — the rendered name for a Struct and a GenericInstance alike — and replaces struct_decl_name, whose two arms disagreed.

A rendered name is a lossy encoding of a pair, so any reader that decodes it back into a pair is a silent dependency on the encoding. get_struct_info_from_type reverse-looked its rendering up in mangled_to_key to recover (name, impl_type_args); those are exactly what the struct now stores, and the round trip is deleted.

One derivation on both sides

A name minted for a definition and a name built to look it up must come from one function, or nothing makes them agree:

The regression test for the first asserts the two functions agree rather than pinning either one's output — a test that pins one spelling passes throughout.

A surviving type must be readable

TypeTable::retain guarantees get(id) never panics for a surviving id. A monomorphized struct records its arguments as they were before erasure while the reachability walk reaches types through the erased view, so nothing kept a flags argument's own id alive: the struct survived spelling itself with an id that no longer resolved. retain closes over each surviving struct's type_args transitively, the same reasoning that motivated the redirects closure.

The fusions that remain

ResolvedType::Newtype bakes its arguments into the head (MyArray<i32>), so impl_receiver_key and newtype_own_name hand the impl index a name no impl header writes. The guard stopping a newtype's own method from being retargeted at its base therefore never fires for a generic newtype. Both sites split the head by hand; step 6 is the honest fix.

A method name records its module twice — core:prelude/string.wado/core:prelude/string.wado/String::with_capacity — because struct_name returns a module-qualified head and MangledName::in_module prefixes the defining module again. It is redundant, not wrong: the key is (impl module, qualified struct, trait, method) and both sides build it the same way. Neither half is removable alone. Without the module prefix a builtin receiver loses its only qualifier, so two modules implementing Display for i32 collide; with a local struct head, impl Foo for a/T and impl Foo for b/T written in c collide as c/T^Foo::m. Only a key carrying the two modules as separate fields removes it.

Consequences

The compiler stops accepting the class of code this refactor kept producing. A name cannot be built without saying which namespace it is in, and cannot be used where another is expected.

Costs and risks:

Remaining work