Skip to content

Fix generic match exhaustiveness so null can't leak into a non-nullable T (B-633)#3904

Draft
antoniosarosi wants to merge 1 commit into
canaryfrom
antonio/b-633
Draft

Fix generic match exhaustiveness so null can't leak into a non-nullable T (B-633)#3904
antoniosarosi wants to merge 1 commit into
canaryfrom
antonio/b-633

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

⚠️ ON HOLD — do not merge / enqueue. Blocked on @2kai2kai2's TIR type‑algebra + interface‑soundness rework, which is an explicit ongoing multi‑PR series (#3896: "this PR is part of an ongoing process so further PRs will be forthcoming", runtime "partially uses the canonical type algebra"; #3852: interface‑soundness code "currently mostly unused"). This PR changes TIR exhaustiveness (union_targets_for_pattern) and relies on reified interface membership (type_implements) — both squarely in that rework's path. It must land only after the series completes, then be rebased and re‑verified (the interface special‑casing here may be subsumed by the unified interface soundness). Converted to Draft until then.

📍 Status — where we stopped (resume from here)

The approach is CodeRabbit‑approved. Pushed commit ea1446c53 implements the two‑part fix (documented below). Two items remain, both saved as uncommitted WIP in the worktree ~/orca/workspaces/baml/antonio-b-633:

  1. Closure‑value edge in the reified is_type test — WIP fix on disk, not yet committed / verified.
    Running the full CI suite (not just baml_tests) surfaced a real panic: value_matches_reified_ty reused value_concrete_runtime_ty, which is interface‑receiver‑only and unreachable!‑panics on Function/Closure/Future values (bex_vm/src/vm.rs ~1996). Two bex_engine tests hit it — while_let_closure_captures_fresh_cell_per_iteration and test_closures_in_loop_vars — because a closure value reaches the reified test.
    WIP fix (uncommitted, vm.rs +46/−7): make the test total via a new is_reified_type_subject guard (only primitives / class‑instances / enum‑variants / type / array / map are valid value_concrete_runtime_ty subjects) plus type‑tag checks for Function/Future targets.
    ⚠️ Still needs verification that this makes the 2 tests pass with correct results (not merely stop panicking) — i.e. confirm the closure reaching the test is a legit refutable let x: T, and not an irrefutable bind that shouldn't emit a type test at all (if the latter, gate it out in lowering instead of tolerating it in the VM).

  2. baml_cli describe snapshot regen. The stdlib iter.baml change (added null => Done {} arm to ArrayIterator.next) shifts the describe baml listing; baml_cli__describe_command_tests__render_builtin_package_listing.snap needs regenerating (a .snap.new is staged).

Resume checklist (once Kai's series lands):

  • Rebase antonio/b-633 onto the new canary; reconcile union_targets_for_pattern with the reworked TIR (the fix may simplify or partly disappear).
  • Finish item 1; confirm the 2 closure tests pass with correct results + soundness (E0062, no null‑leak) + no‑regression (iter_array_nullable_elements[3,1,4,3], iter_flatten[1,2,3,4]).
  • Run the full CI locally: cargo nextest run --all-features -E 'not package(baml_tests) and not package(/^sdk_test_/) and not binary(=pack_e2e)' and cargo test -p baml_tests -- --skip parser_stress → 0 failed; regen baml_cli (+ any flagged) snapshots; cargo clippy --all-targets --all-features -- -D warnings clean; cargo fmt.
  • gh pr ready 3904 (un‑draft) and enqueue.

Bug

A generic match arm let x: T over a union scrutinee T | <concrete> (where T is a rigid type variable) was treated as covering the concrete member too. So this was wrongly deemed exhaustive and compiled, letting null bind into a non-nullable T at runtime:

function bad_pop<T>(b: Box<T>) -> T {
    match (b.items.pop()) {   // .pop() : T | null
        let x: T => x         // no `null` arm, yet accepted
    }
}

The concrete control match (v: int?) { let x: int => x } correctly errored E0062. The bug was specific to a type-variable arm. It also produced a symmetric false E0063 "unreachable arm": in match (v: T?) { let x: T => …, null => … } the explicit null => arm was reported unreachable.

(Note: the ticket title "pop() erases the ?" is inaccurate — pop()/.at() correctly return T | null; the defect was in exhaustiveness/reachability dispatch.)

Root cause

Staticunion_targets_for_pattern (baml_compiler2_tir/src/builder.rs) decides which union members a pattern targets via a symmetric structural overlap, and atoms_overlap reports a TypeVar as overlapping everything. So a bare let x: T pattern claimed the concrete null/string member, making the match exhaustive and shadowing the explicit concrete arm.

Runtime — once let x: T no longer over-claims (so it is not the exhaustive-last catch-all), its runtime type test was emitted, but a bare TypeArgRef fell through to a constant-false in codegen (emit.rs, "these don't arise from pattern matching today"). With the test always false, the let x: T arm never matched — which would break every array iterator (ArrayIterator.next relied on the old catch-all).

Fix

  1. Static (directional overlap)builder.rs: a bare top-level TypeVar pattern claims only the union's TypeVar member(s), never a concrete member. Mirrors the existing member-side rule. This restores E0062 and removes the false E0063.
  2. Reified runtime test — a let x: T arm now resolves T against the frame's realized type args and tests the value against the resolved type, reusing the machinery B-634 (Honor subtyping in generic class match arms' reified type-arg check (B-634) #3902) introduced for class match arms:
    • lower.rs: route a bare let x: T through tir2_to_dispatch_guard_template (covariant TypeArgRefOrWildcard), the same path as the Class<T> arm.
    • emit.rs: emit a reified is_type (a Type constant carrying the template) instead of constant-false.
    • vm.rs: IsType substitutes the template from frame.type_args and runs an interface-aware value-membership test (is_subtype_of for concrete/union targets; type_implements when T resolves to an interface).

This single approach fixes both the soundness hole and keeps generic iteration working:

  • is_type(null, int?)truelet x: T binds it (nullable T yields its stored null).
  • is_type(null, int)false → falls to the null arm (no leak into int).
  • is_type(ArrayIterator, Iterable)true via type_implements (a class value matches an interface T it implements) → flatten/interface-typed iterators keep working.

The one stdlib change: ArrayIterator.next gains the now-required null arm (unreachable at runtime — stored nulls match let value: T).

Tests

  • diagnostic_errors/generic_typevar_matchE0062 for let x: T over T?/T | string via .pop(), .at(), bare T?, and T | string.
  • compiles/patterns_new — both arm orders (let x: T first and null first) and Done-first compile with no E0062/E0063.
  • ns_patterns_new_runtime e2e — both arm orders + .pop()/.at() round-trip; null routes to the null arm for non-nullable T and binds to let x: T for nullable T.
  • match_union_typevar — updated the reachability regression to assert concrete arms after a bare let x: T stay reachable.

Before / after

case before after
match (v: T?) { let x: T => x } compiles (unsound) E0062 missing null
match (v: T?) { let x: T => …, null => … } false E0063 accepted, both orders
iter_array_nullable_elements (int?[]) regressed to end-early [3, 1, 4, 3]
iter_flatten (interface-typed) regressed to [] [1, 2, 3, 4]

Verification

https://linear.app/boundaryml2/issue/B-633

Summary by CodeRabbit

  • New Features
    • Improved runtime matching for generic type variables, so let x: T now works correctly alongside null and other concrete cases.
    • Added support for resolving reified type checks during runtime dispatch.
  • Bug Fixes
    • Fixed cases where empty arrays, out-of-bounds access, or nullable inputs could route to the wrong match arm.
    • Preserved reachability of concrete match arms when a generic arm is present.
  • Tests
    • Added regression coverage for generic pattern matching, nullable unions, and exhaustiveness behavior.

…able `T` (B-633)

A `match` arm `let x: T` over a union scrutinee `T | <concrete>` (T a rigid
type variable) was treated as covering the concrete member too, so
`match (v: T?) { let x: T => x }` was wrongly deemed exhaustive and `null`
could bind into a non-nullable `T` at runtime — while the concrete control
`match (v: int?) { let x: int => x }` correctly errored E0062.

Static root cause: `union_targets_for_pattern` used a symmetric overlap where
`atoms_overlap` reports a `TypeVar` as overlapping everything, so a bare
`let x: T` pattern claimed concrete union members. Fix: make the pattern-side
overlap directional — a bare top-level `TypeVar` pattern claims only the
union's `TypeVar` member(s), never `null`/`string`. This also removes a
symmetric false E0063 "unreachable arm" on an explicit `null =>` arm.

Runtime root cause: once `let x: T` is no longer an over-claiming catch-all,
its runtime type test was emitted as constant-`false` (a bare `TypeArgRef`
erased to `Void`), so the arm never matched. Fix: reify the test — resolve
`T` against the frame's realized type args and test the value against the
resolved type. A class value matches an interface `T` it implements (via
`type_implements`), so nullable-`T` array iteration and interface-typed
iterators keep working.

- baml_compiler2_tir/src/builder.rs: pattern-side directional overlap.
- baml_compiler2_mir/src/lower.rs: route a bare `let x: T` through
  `tir2_to_dispatch_guard_template`, matching B-634's class-arm reified path.
- baml_compiler2_emit/src/emit.rs: emit a reified `TypeArgRef` `is_type` test
  instead of constant-false.
- bex_vm/src/vm.rs: `IsType` resolves the `Type` template against the frame
  and runs an interface-aware value-membership test.
- stdlib ns_iter/iter.baml: ArrayIterator.next gains the now-required `null`
  arm (unreachable at runtime — stored nulls match `let value: T`).
- Tests: diagnostic_errors/generic_typevar_match (E0062), compiles/patterns_new
  (both arm orders), ns_patterns_new_runtime e2e, match_union_typevar
  reachability.
@linear

linear Bot commented Jul 2, 2026

Copy link
Copy Markdown

B-633

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 2, 2026 1:41am
promptfiddle Ready Ready Preview, Comment Jul 2, 2026 1:41am
promptfiddle2 Ready Ready Preview, Comment Jul 2, 2026 1:41am

Request Review

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes generic TypeVar pattern-match overlap logic so a let x: T arm no longer shadows concrete union members (e.g., null, string, IterDone). It adds frame-aware IsType dispatch through MIR lowering, the emitter, and the VM, fixes ArrayIterator.next's null handling, and adds/updates compiler and runtime tests.

Changes

Generic TypeVar pattern-match fix

Layer / File(s) Summary
TIR overlap directionality fix
baml_language/crates/baml_compiler2_tir/src/builder.rs
pattern_claims_typevar overlap check is changed to scan non-TypeVar atoms only, requiring overlap with a concrete atom instead of the whole natural type, with updated explanatory comments.
Frame-aware IsType dispatch through MIR and emitter
baml_language/crates/baml_compiler2_mir/src/lower.rs, baml_language/crates/baml_compiler2_emit/src/emit.rs
lower_pattern_test routes in-scope bare TypeVar patterns through a dispatch-guard template, and is_type gains a TyTemplate::TypeArgRef/TypeArgRefOrWildcard arm emitting IsType with a ConstValue::Type constant.
VM runtime type matching
baml_language/crates/bex_vm/src/vm.rs
New value_matches_reified_ty helper checks unions, interfaces (via type_implements), and subtypes; OpCode::IsType now resolves ConstValue::Type templates by substituting frame type_args before checking.
ArrayIterator null-arm fix
baml_language/crates/baml_builtins2/baml_std/baml/ns_iter/iter.baml
ArrayIterator.next adds a null match arm returning Done {} with updated exhaustiveness comments.
Compiler and runtime tests
baml_language/crates/baml_tests/baml_src/ns_patterns_new_runtime/patterns_new_runtime.baml, baml_language/crates/baml_tests/projects/compiles/patterns_new/patterns_new.baml, baml_language/crates/baml_tests/projects/diagnostic_errors/generic_typevar_match/generic_typevar_match.baml, baml_language/crates/baml_tests/tests/match_union_typevar.rs
Adds generic B-633 runtime tests for T?/pop/at fallback routing, compile-only tests for `T

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Lower as lower_pattern_test
  participant Emit as emit.rs is_type
  participant VM as bex_vm IsType
  Lower->>Lower: detect TypeVar in scope for let x: T
  Lower->>Emit: emit_is_type_template_branch(TyTemplate)
  Emit->>Emit: build ConstValue::Type(template)
  Emit->>VM: IsType instruction with ConstValue::Type operand
  VM->>VM: substitute frame.type_args into template
  VM->>VM: value_matches_reified_ty(value, resolved type)
Loading

Possibly related PRs

  • BoundaryML/baml#3340: Both PRs change the shared runtime type-check path around the IsType opcode, extending or replacing type-tag/instanceof-style checks.
  • BoundaryML/baml#3417: This PR's lower_pattern_test/builder.rs overlap fixes build directly on the pattern-lowering pipeline changed by the retrieved PR.
  • BoundaryML/baml#3749: Both PRs modify ArrayIterator.next's null-handling/termination logic in iter.baml.

Poem

A rabbit hopped through types so free,
Found T was shadowing what shouldn't be.
Now null and string both get their turn,
And IsType checks the frame to learn.
Hop, hop, exhaustive victory! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main fix: generic match exhaustiveness around T and null handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch antonio/b-633

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
baml_language/crates/baml_tests/projects/compiles/patterns_new/patterns_new.baml (1)

289-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the IterDone let-first ordering.

This only covers IterDone before let x: T; add the mirror case so the compile test locks the concrete-arm-after-TypeVar reachability regression for non-null sentinels too.

Proposed test addition
 function typevar_done_first<T>(v: T | IterDone, fallback: T) -> T {
     match (v) {
         IterDone => fallback,
         let x: T => x,
     }
 }
+
+function typevar_done_let_first<T>(v: T | IterDone, fallback: T) -> T {
+    match (v) {
+        let x: T => x,
+        IterDone => fallback,
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@baml_language/crates/baml_tests/projects/compiles/patterns_new/patterns_new.baml`
around lines 289 - 294, The compile test for typevar_done_first only covers the
pattern order where the concrete IterDone arm comes before the let binding; add
the mirror case using the same function-style pattern so it also tests let x: T
before IterDone. Update the patterns_new test content to include a matching
function or match block that exercises the reversed ordering, using IterDone and
typevar_done_first as reference points, so the regression is locked for non-null
sentinels too.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@baml_language/crates/baml_tests/projects/compiles/patterns_new/patterns_new.baml`:
- Around line 289-294: The compile test for typevar_done_first only covers the
pattern order where the concrete IterDone arm comes before the let binding; add
the mirror case using the same function-style pattern so it also tests let x: T
before IterDone. Update the patterns_new test content to include a matching
function or match block that exercises the reversed ordering, using IterDone and
typevar_done_first as reference points, so the regression is locked for non-null
sentinels too.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 78cd9649-7227-4912-ba1e-8118e48bd28b

📥 Commits

Reviewing files that changed from the base of the PR and between 5ba654d and ea1446c.

⛔ Files ignored due to path filters (20)
  • baml_language/crates/baml_tests/snapshots/baml_src/patterns_new_runtime.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__01_lexer__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__02_parser__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/patterns_new/baml_tests__compiles__patterns_new__10_formatter__patterns_new.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__01_lexer__generic_typevar_match.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__02_parser__generic_typevar_match.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generic_typevar_match/baml_tests__diagnostic_errors__generic_typevar_match__10_formatter__generic_typevar_match.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generics/baml_tests__diagnostic_errors__generics__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/generics/baml_tests__diagnostic_errors__generics__05_diagnostics.snap is excluded by !**/*.snap
📒 Files selected for processing (9)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_iter/iter.baml
  • baml_language/crates/baml_compiler2_emit/src/emit.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_tests/baml_src/ns_patterns_new_runtime/patterns_new_runtime.baml
  • baml_language/crates/baml_tests/projects/compiles/patterns_new/patterns_new.baml
  • baml_language/crates/baml_tests/projects/diagnostic_errors/generic_typevar_match/generic_typevar_match.baml
  • baml_language/crates/baml_tests/tests/match_union_typevar.rs
  • baml_language/crates/bex_vm/src/vm.rs

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 21.7 MB 9.2 MB file 21.5 MB +123.0 KB (+0.6%) OK
packed-program Linux 🔒 15.8 MB 6.6 MB file 15.6 MB +153.2 KB (+1.0%) OK
baml-cli macOS 🔒 16.6 MB 8.0 MB file 16.6 MB +82.8 KB (+0.5%) OK
packed-program macOS 🔒 12.2 MB 5.8 MB file 12.1 MB +99.6 KB (+0.8%) OK
baml-cli Windows 🔒 18.2 MB 8.3 MB file 18.1 MB +112.6 KB (+0.6%) OK
packed-program Windows 🔒 13.1 MB 5.9 MB file 13.0 MB +134.8 KB (+1.0%) OK
bridge_wasm WASM 14.5 MB 🔒 4.1 MB gzip 4.1 MB +46.9 KB (+1.2%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

@antoniosarosi

Copy link
Copy Markdown
Contributor Author

Parking as draft — holding until @2kai2kai2's type-algebra / interface-soundness rework series lands. This is a TIR exhaustiveness fix (union_targets_for_pattern) plus a reified runtime is_type with interface membership (type_implements), and that overlaps the ongoing rework (#3896: "further PRs will be forthcoming"; runtime "partially uses the canonical type algebra"; #3852 interface-soundness code "currently mostly unused"). Once the series completes I'll rebase onto the new TIR and re-verify — the runtime interface special-casing here may be subsumed by the unified interface soundness. Approach is CodeRabbit-approved; only remaining local work is a closure-value edge in the reified test + a baml_cli describe snapshot regen.

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