Files
sx/issues/0064-nondollar-type-param-silent-empty-struct.md
agra 63b512a182 fix(types): extend unknown-type check into function bodies (issue 0064)
The signature/field check missed body-level type positions: a local
annotation naming a non-existent type flowed through the empty-struct stub
untouched, so `v: Coordnate = 5` silently compiled and ran (the value
dropped) — an invalid program accepted with no diagnostic.

`checkUnknownTypeNames` now also walks each main-file function body
(`checkBodyTypes`): local var/const type annotations — including inside
if / loop / match / push / defer / onfail blocks and decl-value blocks — are
validated with the enclosing function's generic params in scope, and
body-local `T :: struct/enum/union` declarations are collected first
(`collectBodyDeclNames`) so legitimate locals aren't false-flagged. Nested
function/closure bodies are their own scope and are not descended (safe
under-coverage); explicit `cast(T)` already surfaces its own `unresolved`
diagnostic and is left to it.

Regression: examples/1113 (local annotation of a non-existent type, exit 1).
2026-06-02 10:41:29 +03:00

4.5 KiB

0064 — non-$ T: Type function param used as a type silently yields {}

RESOLVED (2026-06-02). Root cause as diagnosed: an identifier in a type position that resolved to nothing fell through to type_bridge.resolveTypeName's empty-struct stub, silently interning a 0-field struct under the name. Fix (option 2, surfaced as a diagnostic): a new post-scan pass checkUnknownTypeNames ([src/ir/lower.zig], Pass 1f) walks every main-file function signature and non-generic struct field type and rejects any leaf name that is not a primitive, an in-scope generic param ($T / type_params), a declared type, or a real (non-stub) registered type. The load-bearing empty-struct stub is left intact (forward references + foreign-class opaque types still rely on it during the scan); the pass runs after scanning and before body lowering, so core.zig's hasErrors() halts the build before any stub reaches codegen. A value param used as a type gets the tailored hint "'T' is a value parameter, not a type; introduce a generic type parameter with $T: Type"; a genuine unknown name gets "unknown type 'X'". Imported concrete types are recognized via the type table (findByName), so cross-module references aren't false-flagged; inline compound spellings ([:0]u8), arbitrary- width ints (u1/u2), and $-introduced generics (-> $R) are all exempted. The pass also walks function bodies (checkBodyTypes + collectBodyDeclNames): local var / const type annotations — including inside if / loop / match / push / defer / onfail blocks and decl-value blocks — are checked with the enclosing function's generic params in scope, and body-local T :: struct/enum/ union declarations are collected so they aren't false-flagged. This closes the silent body-level hole where v: Coordnate = 5 (a non-existent type) compiled and ran with the value dropped. Nested function / closure bodies are their own scope and are not descended (safe under-coverage); explicit cast(T) already has its own unresolved diagnostic and is left to it. Regression tests: examples/1111-diagnostics-nondollar-type-param-rejected.sx (tailored hint), examples/1112-diagnostics-unknown-type-name-rejected.sx (typo'd field type), and examples/1113-diagnostics-unknown-type-local-var-rejected.sx (body-level local annotation) — all exit 1. Suite: 348 passed, 0 failed.

Symptom

A function parameter declared T: Type (or lowercase T: type) — i.e. WITHOUT the $ generic-type-parameter sigil — that is then referenced in a type position (-> T, Closure() -> T, etc.) silently resolves T to a fabricated empty struct {} instead of the caller's argument type. The function "runs" but produces garbage (the value renders as T{}), with no diagnostic.

idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
main :: () -> s32 {
    print("{}\n", idwrap(s32, closure(() -> s32 { return 7; })));  // prints "T{}", want 7
    return 0;
}

The correct, working form is the generic $T: Type (the $ introduces the generic type parameter — see specs.md §"$ generic type parameter introduction"). With $T, the binding is established and the result is 7.

So this is not a miscompile of a valid program — it's a missing diagnostic for a misuse: a non-generic Type-typed value param can't be used as a type, and should be rejected (or the $ requirement explained), not silently turned into an empty struct.

Reproduction

See above. Compare idwrap :: ($T: Type, …) (works, prints 7) vs idwrap :: (T: Type, …) (prints T{}).

Investigation prompt

In src/ir/lower.zig, resolveTypeWithBindings resolves a .type_expr named T by checking type_bindings (works for $T, which buildTypeBindings registers). For a non-$ T: Type param there is no binding, so resolution falls through to type_bridge.resolveAstType, which fabricates an empty-struct stub for the unknown name T — the classic "silent empty struct" the CLAUDE.md REJECTED-PATTERNS warn about. Fix options: (1) at scan/sema time, reject referencing a non-$ Type-typed param in a type position with a diagnostic ("type parameter must be introduced with $ — write $T: Type"); or (2) make resolveAstType return .unresolved + a diagnostic for an unknown bare type name in a generic-eligible position, instead of stubbing {}. Deferred — orthogonal to ERR; the working $T idiom exists. Low priority but should not stay silent.