Skip to content

Unify if-else branch types when the value is used - #27

Merged
wtholliday merged 1 commit into
mainfrom
fix-if-else-unify
Aug 8, 2026
Merged

Unify if-else branch types when the value is used#27
wtholliday merged 1 commit into
mainfrom
fix-if-else-unify

Conversation

@wtholliday

Copy link
Copy Markdown
Collaborator

Follow-up to #26, from a code review of that PR.

The bug

Typing a var declaration as void fixed the f32 half of #22, but it moved the i32 half rather than removing it. An if-else whose else branch ends in a declaration now fails the result_ty == else_ty test in the Cranelift and LLVM lowerings, so the whole expression degrades to the iconst(I32, 0) placeholder and the then-branch value is thrown away:

g(c: bool) -> i32 {
    var y = if c { 1 } else { var z = 2 }
    y
}

g(true) returned 1 before #26 and 0 after, with no diagnostic. The VM backend still returned 1, so the two backends disagreed — cargo fuzz run differential should have been able to find this.

The root cause is that Expr::If took its then-branch type and never looked at the else branch, so branch disagreement was left for codegen to discover, and codegen's only recourse is a placeholder — a wrong answer instead of an error.

The fix

Unify the two branches in the checker, so the disagreement is a type error at the if-else.

The catch is that lyte blocks evaluate to their last expression and an assignment is an expression, so an if-else in statement position routinely has branches with incidental, differing types — the stdlib's ftoa does exactly this. So mark_value_positions records which expressions have their value used (discarded in a non-final block element and in a loop body, inherited through block tails and branches), and branches are unified only where the value is actually used.

Also in the checker:

  • let gets the same statement treatment as var. It still leaked the binding's type, so f() -> f32 { let u = 1.0 } compiled while the byte-identical var form was rejected.
  • Post-check passes gate on errors from the function being checked rather than on self.errors, which accumulates across decls — one bad function disabled constraint solving and every post-check pass for all later ones.
  • check_void_declarations skips expressions check_expr never visited instead of bailing out when any error exists. Unvisited expressions keeping the Void fill value was the actual reason for that gate.
  • Both post-solve passes share one solved_types() call instead of each running the substitution over every expression in the function.
  • check() calls check_decl rather than repeating its body, so a new pass only has to be added in one place.

In the JIT, merge_ty is Some exactly when the if-else produces a value, so use it directly and drop the parallel is_value boolean. check_merge_arg's doc claimed it caught the silently-wrong-answer case; it can't, since that case has matching Cranelift types — branch unification in the checker is what rules that class out.

Expr::subexprs moves from a nested fn in safety_checker to impl Expr, since the new walk needs it too.

Not changed

The review also flagged that #26 rejects let r = p(1) where p returns void, which used to compile. That looks intentional — it's what checker/var_decl_not_a_value.lyte relies on, and a void binding can't be read — so it stays. Worth knowing it's a source-breaking change for anything that bound a void call.

Tests

  • checker/if_branch_void_else.lyte — the miscompile above, now a type error
  • checker/if_branch_value_mismatch.lyteif c { 1 } else { 2.0 } in value position
  • checker/let_decl_not_a_value.lytelet in tail position
  • if_stmt_branch_types.lyte — statement-position branches with differing tail types keep working, across all four backends

cargo test --workspace passes: 351 unit tests, 306 golden tests on jit/vm/asm/stack.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc

Follow-up to #26. Typing a `var` declaration as void fixed the f32 half of
issue #22, but it moved the i32 half rather than removing it: an if-else whose
else branch ends in a declaration now fails the `result_ty == else_ty` test in
the Cranelift and LLVM lowerings, so the whole expression degrades to the
`iconst(I32, 0)` placeholder and the then-branch value is thrown away.

    g(c: bool) -> i32 {
        var y = if c { 1 } else { var z = 2 }
        y
    }

g(true) returned 1 before #26 and 0 after, with no diagnostic — and the VM
backend still returned 1, so the two backends disagreed.

The root cause is that `Expr::If` took its then-branch type and never looked at
the else branch, leaving branch disagreement for codegen to discover. Codegen's
only recourse is a placeholder, i.e. a wrong answer. Unify the two branches in
the checker instead, so the disagreement is a type error at the if-else.

The catch is that lyte blocks evaluate to their last expression and an
assignment is an expression, so an if-else in statement position routinely has
branches with incidental, differing types (the stdlib's ftoa does exactly
this). `mark_value_positions` records which expressions have their value used —
discarded in a non-final block element and in a loop body, inherited through
block tails and branches — and the branches are only unified where the value is
actually used.

Also in the checker:

- `let` gets the same statement treatment as `var`. It still leaked the
  binding's type, so `f() -> f32 { let u = 1.0 }` compiled while the
  byte-identical `var` form was rejected.
- Post-check passes gate on errors from the function being checked rather than
  on `self.errors`, which accumulates across decls — one bad function disabled
  constraint solving and every post-check pass for all later ones.
- `check_void_declarations` skips expressions `check_expr` never visited
  instead of bailing out when any error exists. Unvisited expressions keeping
  the Void fill value was the actual reason for the gate.
- Both post-solve passes share one `solved_types()` call instead of each
  running the substitution over every expression in the function.
- `check()` calls `check_decl` rather than repeating its body, so a new pass
  only has to be added in one place.

In the JIT, `merge_ty` is Some exactly when the if-else produces a value, so
use it directly and drop the parallel `is_value` boolean. `check_merge_arg`'s
doc claimed it caught the silently-wrong-answer case; it can't, since that case
has matching Cranelift types. Branch unification in the checker is what rules
that class out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc
@wtholliday
wtholliday merged commit 475c9b9 into main Aug 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant