Files
sx/issues/0189-field-access-in-type-position-fabricates-empty-struct.md

3.0 KiB

0189 — non-type expression in type position silently fabricates an empty struct

Status: OPEN

Symptom

A non-type expression used in type position — e.g. a field_access like g.a — is silently accepted and resolved to a bogus zero-field struct {} instead of being rejected with a diagnostic.

  • Observed: x : g.a = ---; compiles with exit 0, emitting LLVM alloca {} (an empty struct) for x. Tuple(i32, g.a) likewise yields { i32, {} } with a store ... zeroinitializer.
  • Expected: a user-facing "not a type" diagnostic at the offending expression and a clean non-zero exit (never a fabricated empty struct reaching codegen).

This is the classic silent-fallback-default failure mode: a lookup that should fail returns a "reasonable-looking" value ({}) and ships invisibly.

Reproduction

S :: struct { a: i32; }
g : S = .{ a = 1 };

main :: () -> i32 {
    x : g.a = ---;   // `g.a` is a runtime VALUE, not a type
    0
}

Run: ./zig-out/bin/sx run repro.sx → exits 0 (should error). Inspect ./zig-out/bin/sx ir repro.sxx is alloca {}.

The tuple form x : Tuple(i32, g.a) = ---; reproduces the same fabrication for the second element. (The bug is not tuple-specific — it predates and is independent of the Tuple(...) syntax cutover; the plain g.a case above has no tuple at all.)

Investigation prompt

The fabrication lives in the type-resolution bridge, not in the tuple code. In src/ir/type_bridge.zig, resolveAstType's handling of a field_access (and likely any non-type-shaped expression) in type position falls through to building a zero-field stub struct rather than returning the .unresolved sentinel + a diagnostic.

The tuple-element validation arm in src/ir/lower/generic.zig (and the literal screen near src/ir/lower.zig:960) only rejects the five literal tags (int/float/string/bool/null_literal); it leans on resolveCompound's .unresolved propagation to catch everything else — which works only when the element actually resolves to .unresolved. A field_access resolves to the fabricated {} stub instead, so it slips through.

Likely fix (pick one, verify against the repro):

  1. In type_bridge.resolveAstType, the field_access-in-type-position arm (and any non-type-shaped expression) should emit a diagnostic via self.diagnostics.addFmt(.err, span, "...") and return .unresolved, never a fabricated empty struct.
  2. Broaden the type-element screen to reject any non-type-shaped element node up front using type_bridge.isTypeShapedAstNode (already used by resolveTupleLiteralTypeArg), instead of the explicit literal-tag allowlist.

Verification: the repro above must error with a clear "not a type" diagnostic and a non-zero exit; Tuple(i32, g.a) must reject too; the existing examples/diagnostics/1116 (literal non-type element) must keep passing. Add a regression example for the field_access case.

(Found by adversarial review during the tuple-syntax cutover, commit 989e18b7.)