# 0133 — assigning a struct LITERAL to a union member panics ("unresolved type reached LLVM emission") > **RESOLVED (2026-06-13).** Root cause: `lowerAssignment`'s `.field_access` > target-type path used `getStructFields`, which returns nothing for a > `union`, so a union-member LHS never set `target_type` and the RHS struct > literal lowered as `.unresolved` → LLVM-emission tripwire. Fix: a single > pure field-matching resolver `fieldLvalueResolve` (in `src/ir/lower/stmt.zig`) > that both `fieldLvaluePtr` (builds GEPs) and the target-type path > (`res.valueType()`) consume — covering union direct + promoted members, > tuple/vector lanes, and structs, so the lvalue-pointer path and the > target-type path can't diverge. Landing this required first fixing the > latent [issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md) > (the unified resolver newly types tuple-element LHSs, which routed > `examples/0540`'s `c.sources.0 = xx sources[0]` through pack-index protocol > erasure). Regression test: > [examples/0184-types-union-member-struct-literal-assign.sx](../examples/0184-types-union-member-struct-literal-assign.sx). > The § Confirmed fix block below records the exact patch that landed. ## Symptom One-line: `u.b = .{ ... }` where `b` is a NAMED-struct member of a plain `union` compiles to an `.unresolved`-typed `struct_init` and trips the LLVM-emission tripwire. The RHS struct literal never receives its target type (the union member's type), so it lowers as `.unresolved`. - **Observed:** `thread … panic: unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted` (`src/backend/llvm/types.zig:176`), reached from `emitStructInit` (`src/backend/llvm/ops.zig:1211`) because the `struct_init` instruction's `ty` is `.unresolved`. - **Expected:** the literal types itself as the union member's struct type (here `S`) and stores into the member — exactly as it already does when the left-hand side is a STRUCT field. This is PRE-EXISTING (reproduces on `master` / before any issue-0132 work) and ORTHOGONAL to type-name resolution: it reproduces with a unique, non-colliding type name. Surfaced while testing issue 0132's broader-latent fix (making enum/union payload registration visibility-aware) — that fix makes a *colliding*-name union member resolve to the correct type, at which point this separate codegen bug is what blocks the end-to-end union case. ## Reproduction Minimal, standalone (only `modules/std.sx`): ```sx #import "modules/std.sx"; S :: struct { code: i64; } U :: union { a: i64; b: S; } main :: () { u : U = ---; u.b = .{ code = 9 }; // <-- panics: struct literal has no target type print("code={}\n", u.b.code); } ``` Run: `./zig-out/bin/sx run issues/0133-union-member-struct-literal-assign-unresolved-panic.sx` → panics today; the fix should make it print `code=9`, exit 0. ### Bisection (what does / does not trigger it) | Variant | Result | |---|---| | `u.b = .{ code = 9 }` (union member ← struct LITERAL) | **PANICS** | | `o.b = .{ code = 9 }` where `o : Outer = struct { a; b: S }` (STRUCT member ← literal) | **OK** | | `s : S = .{ code = 9 }; u.b = s` (union member ← pre-made value) | **OK** | | `u : U = ---` then only read (no literal assign) | **OK** | So the trigger is exactly the conjunction **(LHS is a union member) AND (RHS is a struct literal)**. A struct-field LHS propagates the target type to the literal; a pre-made value needs no target type. Only the union-member-lvalue + literal-RHS combination drops it. ## Investigation prompt > Assigning a struct literal to a NAMED-struct member of a plain `union` > panics with "unresolved type reached LLVM emission". Repro: > `issues/0133-union-member-struct-literal-assign-unresolved-panic.sx` > (expect a panic today; the fix should make it print `code=9`, exit 0). > > The `struct_init` instruction for the RHS literal `.{ code = 9 }` has > `ty == .unresolved` — the literal was lowered without a target type, so > it could not resolve to the union member's struct type `S`. The panic > is the codegen tripwire in `src/backend/llvm/types.zig:176` > (`toLLVMTypeInfo`), reached from `emitStructInit` > (`src/backend/llvm/ops.zig:1211`). > > Root area: assignment lowering in `src/ir/lower.zig` — > `lowerAssignment`'s `.field_access` target path. Issue 0094 already > routes the lvalue POINTER through the shared `fieldLvaluePtr` (which > correctly resolves union/tagged-union direct members — that's why a > pre-made value stores fine). The gap is the RHS TARGET TYPE: for a > STRUCT-field LHS the code sets `self.target_type` to the field's type > before lowering the RHS (so a struct literal types itself), but for a > UNION-member LHS that target-type propagation is missing, so the > literal lowers under a null/unresolved target → `struct_init.ty == > .unresolved`. > > Suspected fix: before lowering the RHS expression in > `lowerAssignment`'s field-access path, compute the LHS member's type > for union / tagged-union members too (reuse the same member-type lookup > `fieldLvaluePtr` already performs — ideally have it RETURN the resolved > field type, or factor a `fieldLvalueType` helper, so the lvalue-pointer > path and the target-type path cannot diverge — the two-resolver defect > class this codebase keeps burning on) and set `self.target_type` to it > for the RHS lowering. Do NOT paper over with an `.unresolved`→default; > per CLAUDE.md, resolve the real member type or emit a diagnostic. > > Verification: the repro prints `code=9` exit 0; then `zig build && > zig build test` green. Add positive coverage (a union member written > via struct literal, then read back) — extend > `examples/0166-types-union-promoted-member-lvalue.sx` or add a new > `examples/01xx-types-union-member-struct-literal-assign.sx`. When > resolved, also note in issue 0132 that the broader-latent union case is > now demonstrable end-to-end. ## Confirmed fix (landed) Root cause confirmed exactly as the investigation prompt hypothesized: the target-type path in `lowerAssignment`'s `.field_access` case used `getStructFields`, which returns `&.{}` for a `union` (only `.@"struct"` is handled). So a union-member LHS never set `self.target_type`, and the RHS struct literal lowered with no target → `struct_init.ty == .unresolved` → LLVM-emission tripwire. The fix unifies the resolver (per this issue's prompt — "factor a `fieldLvalueType` helper … so the lvalue-pointer path and the target-type path cannot diverge"): a pure `fieldLvalueResolve(obj_ty, field) -> ?FieldResolution` matcher that both `fieldLvaluePtr` (builds GEPs) and the target-type path (`res.valueType()`) consume. With it, the 0133 union repro prints `code=9`, exit 0. **Why it was blocked on 0135:** the unified matcher also resolves *tuple element* LHS types (not just structs, as the old `getStructFields` path did). That makes `c.sources.0 = xx sources[0]` in `examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`, routing `xx sources[0]` through `buildProtocolErasure` → `lowerExprAsPtr(sources[0])` → the pre-existing pack-index-address-of bug (issue 0135). Narrowing the fix to dodge tuples would reintroduce the two-resolver divergence this issue explicitly set out to remove — i.e. a workaround — so 0135 was fixed first, then this landed unchanged. The patch that landed (`src/ir/lower.zig` + `src/ir/lower/stmt.zig`): ```diff diff --git a/src/ir/lower.zig b/src/ir/lower.zig --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ pub const Lowering = struct { pub const lowerAssignment = lower_stmt.lowerAssignment; + pub const fieldLvalueResolve = lower_stmt.fieldLvalueResolve; pub const fieldLvaluePtr = lower_stmt.fieldLvaluePtr; ``` In `src/ir/lower/stmt.zig`, replace the struct-only target-type loop in `lowerAssignment`'s `.field_access` branch: ```diff - if (!obj_ty.isBuiltin()) { - const field_name_id = self.module.types.internString(fa.field); - const struct_fields = self.getStructFields(obj_ty); - for (struct_fields) |f| { - if (f.name == field_name_id) { - self.target_type = f.ty; - break; - } - } - } + // Resolve the LHS member's type via the SAME resolver the lvalue- + // pointer path uses (fieldLvalueResolve), so the RHS target type + // and the store slot can't diverge. Covers union/tagged-union + // direct + promoted members, tuple/vector lanes, and structs — + // not just structs (a plain getStructFields loop returned nothing + // for a union member, leaving a struct-literal RHS untyped → + // struct_init.ty == .unresolved → LLVM-emission panic; issue 0133). + if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| { + self.target_type = res.valueType(); + } ``` Then refactor `fieldLvaluePtr` into a pure `FieldResolution` matcher (`fieldLvalueResolve`) + a thin GEP-builder. Full hunk: ```zig const FieldResolution = union(enum) { union_direct: struct { index: u32, ty: TypeId }, union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId }, indexed: struct { index: u32, ty: TypeId }, fn valueType(self: FieldResolution) TypeId { return switch (self) { .union_direct => |u| u.ty, .union_promoted => |u| u.ty, .indexed => |s| s.ty, }; } }; pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution { if (obj_ty.isBuiltin()) return null; const field_name_id = self.module.types.internString(field); const type_info = self.module.types.get(obj_ty); const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) { .@"union" => |u| u.fields, .tagged_union => |u| u.fields, else => null, }; if (union_fields) |fields| { for (fields, 0..) |f, i| { if (f.name == field_name_id) { return .{ .union_direct = .{ .index = @intCast(i), .ty = f.ty } }; } if (!f.ty.isBuiltin()) { const fi = self.module.types.get(f.ty); if (fi == .@"struct") { for (fi.@"struct".fields, 0..) |sf, si| { if (sf.name == field_name_id) { return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .ty = sf.ty } }; } } } } } return null; } if (type_info == .tuple) { const tup = type_info.tuple; var elem_idx: ?usize = null; if (std.fmt.parseInt(usize, field, 10)) |n| { if (n < tup.fields.len) elem_idx = n; } else |_| { if (tup.names) |names| { for (names, 0..) |nm, i| { if (nm == field_name_id and i < tup.fields.len) { elem_idx = i; break; } } } } if (elem_idx) |idx| { return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } }; } return null; } if (type_info == .vector) { const vidx = Lowering.vectorLaneIndex(field) orelse return null; return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } }; } const struct_fields = self.getStructFields(obj_ty); for (struct_fields, 0..) |f, i| { if (f.name == field_name_id) { return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } }; } } return null; } pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue { const res = self.fieldLvalueResolve(obj_ty, field) orelse return null; switch (res) { .union_direct => |u| { const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty)); return .{ .ptr = ptr, .ty = u.ty }; }, .union_promoted => |u| { const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty)); const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty); return .{ .ptr = ptr, .ty = u.ty }; }, .indexed => |s| { const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty); return .{ .ptr = ptr, .ty = s.ty }; }, } } ``` (`fieldLvalueResolve` is also registered on `Lowering` in `lower.zig` — the first diff hunk above.) Landed after 0135; the repro moved to `examples/0184-types-union-member-struct-literal-assign.sx` and `examples/0540` stays green. ## Notes - Tripwire site (symptom): `src/backend/llvm/types.zig:176` (`toLLVMTypeInfo`, `.unresolved` arm) via `emitStructInit` (`src/backend/llvm/ops.zig:1211`). - Root area (cause): `Lowering.lowerAssignment` `.field_access` target path in `src/ir/lower.zig` — RHS target-type not set for union/ tagged-union members. - Related but distinct: issue 0094 (RESOLVED) fixed the lvalue-POINTER field resolution (missing-field panic + `.i64`/field-0 defaults). This issue is the RHS-literal TARGET-TYPE path, which 0094 did not touch.