Files
sx/issues/0101-postfix-bang-field-miscompile.md
agra 6f2a1dc3dc fix(types): type force-unwrap so opt!.field chains resolve [0101]
ExprTyper.inferType had no `.force_unwrap` arm, so `mk()!` typed as
`.unresolved`. The bind-first form (`v := mk()!; v.field`) worked because
lowerForceUnwrap produces a correctly typed value stored in a slot, but the
chained `mk()!.field` re-derives the receiver type via inferExprType and got
`.unresolved` — the struct-field lookup failed, the field read emitted as
`undef` (garbage), and `mk()!.method()` failed to resolve the method.

Add a `.force_unwrap` arm resolving the operand's optional child type. One
arm fixes every chained form — field, nested `opt!.a.b`, `opt!.method()`
(pointer + value receiver), and `opt![i]` all route receiver typing through
inferExprType.

Regression: examples/0905-optionals-unwrap-field-chain.sx — garbage / compile
error pre-fix, all correct after.
2026-06-06 07:42:17 +03:00

3.2 KiB

0101 — postfix ! chained with .field miscompiles

RESOLVED. A postfix optional force-unwrap chained directly with a member access — opt!.field — read garbage instead of the field, while the bind-first form (v := opt!; v.field) was correct. Sibling chains shared the bug: opt!.method() failed to resolve the method at all (error: unresolved '<method>'), and opt!.a.b / opt![i] were affected the same way.

Symptom — observed vs expected:

#import "modules/std.sx";
S :: struct { id: string; n: s64; }
mk :: () -> ?S { return S.{ id = "hello", n = 42 }; }
main :: () {
    print("chained: {}\n", mk()!.id);   // observed: garbage (e.g. 8362783136)
    v := mk()!; print("bind: {}\n", v.id);  // observed: "hello"  (correct)
}

Expected: mk()!.id prints hello (same as the bind-first form).

Root cause (src/ir/expr_typer.zig, ExprTyper.inferType): the AST-level type-inference switch had no .force_unwrap arm, so mk()! typed as .unresolved (the else fallback). lowerForceUnwrap lowers the unwrap to a correctly-typed value, so the bind form works — v := mk()! stores that typed Ref into a slot and v.field reads it back. But the chained form never materializes a slot: lowerFieldAccess re-derives the receiver type via inferExprType(fa.object) (= inferExprType(mk()!)), got .unresolved, and the struct-field lookup on .unresolved failed — mk()!.id was typed .unresolved/s64 and its value emitted as undef (the print monomorphized pack_s64 with i64 undef, surfacing as a stale stack address). The method chain failed for the same reason: receiver typing returned .unresolved, so method resolution found nothing.

Fix (src/ir/expr_typer.zig:92): add a .force_unwrap arm to ExprTyper.inferType that resolves the operand's optional child type (mirrors lowerForceUnwrap's resolveOptionalInner):

.force_unwrap => |fu| blk: {
    const opt_ty = self.l.inferExprType(fu.operand);
    if (!opt_ty.isBuiltin()) {
        const info = self.l.module.types.get(opt_ty);
        if (info == .optional) break :blk info.optional.child;
    }
    break :blk .unresolved;
},

This is the single root cause for every chained form — field, nested field, method call, and index all route their receiver type through inferExprType. One arm fixes all of them.

Reproduction (standalone, std-only):

#import "modules/std.sx";
Inner :: struct { tag: string; k: s64; }
S :: struct {
    id: string; n: s64; inner: Inner;
    greet :: (self: *S) -> string { return self.id; }
}
mk :: () -> ?S { return S.{ id = "hello", n = 42, inner = Inner.{ tag = "deep", k = 7 } }; }
main :: () {
    print("{}\n", mk()!.id);          // pre-fix: garbage; post-fix: hello
    print("{}\n", mk()!.n);           // pre-fix: garbage; post-fix: 42
    print("{}\n", mk()!.greet());     // pre-fix: error unresolved 'greet'; post-fix: hello
    print("{}\n", mk()!.inner.tag);   // post-fix: deep
}

Regression: examples/0905-optionals-unwrap-field-chain.sx exercises opt!.field (string + int field, chained vs bind-first), opt!.method() (pointer + value receiver), nested opt!.a.b, and opt![i]. Garbage / compile error on pre-fix code; all correct after.