# 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 + runtime-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.
> The walk descends into **nested closure / function bodies** too (`walkBodyTypes`
> + `checkScope`): each scope accumulates its generic params onto the parent's, so
> a closure body still sees the enclosing function's `$T`, and a type annotation in
> any nesting depth is checked. `harvestScopeDecls` collects type-decl names across
> the whole body (including nested scopes) so locals aren't false-flagged. Cast
> targets are handled too: `cast(T)` where `T` is a value-`Type` param (the
> otherwise-silent cast case) gets the tailored hint, while an unknown *literal*
> cast target is left to the existing value-resolution `unresolved` diagnostic (no
> double-report). The only remaining under-coverage is benign (annotations buried in
> AST positions the walker doesn't descend stay unchecked — never a false positive).
> Regression tests: `examples/1111` (tailored hint, signature), `1112` (typo'd field
> type), `1113` (body-level local annotation), `1114` (nested-closure annotation),
> `1115` (`cast` value param) — all exit 1. Suite: 350 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.

```sx
idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
main :: () -> i32 {
    print("{}\n", idwrap(i32, closure(() -> i32 { 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](../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.
