Skip to content

feat(types): infer _ wildcard in type-arg and throws positions (B-230, B-247) - #3887

Merged
antoniosarosi merged 4 commits into
canaryfrom
antonio/type-checker
Jun 30, 2026
Merged

antoniosarosi merged 4 commits into
canaryfrom
antonio/type-checker

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

What

Introduces a _ inference-hole wildcard in type positions, fixing two related typechecker friction bugs, and corrects an imprecise stdlib throw set. Both tickets are fixed by a single shared mechanism (Ty::Infer), which is why they're landed together.

Fixes B-230. Fixes B-247.

B-230 — spawn annotation forced an exact error type

Annotating a spawn result Future<int, never> failed to compile when the body transitively threw (e.g. via baml.sys.sleep throws baml.errors.Io), and there was no way to write a partial annotation:

error: type mismatch: expected Future<int, never>, got Future<int, baml.errors.Io>  (E0001)

Future<V, E> is an invariant container, so the mismatch is correct — never genuinely means "throws nothing". The real gap was the missing partial annotation that TYPE_SYSTEM.md already reserved _ for. Now:

let fs: baml.future.Future<int, _>[] = [spawn { slow(1) }];  // E inferred from the body

The hole is filled from the initializer's actual type, so it stays exact (not erased to unknown): await fs[0] sees the precise error type, and a wrong explicit arg (Future<string, _> against an int body) is still a type error.

B-247 — throws T forced exhaustive re-declaration of stdlib throws

A function declaring throws BadInput that also called any baml.json.* function had to re-list the stdlib throws or drop throws entirely:

error: declared throws is `BadInput`, but this function may also throw
       `baml.json.JsonParseError | baml.json.JsonDecodeError`  (E0096)

throws is exhaustive by design (TYPE_SYSTEM.md), so rather than silently weakening that, this adds an explicit opt-in: a _ member opens the contract.

function handler(s: string) -> Resp throws AppError | _ {
  let v = baml.json.from_string(s);   // stdlib throws absorbed by `_`
  if (bad(v)) { throw AppError {}; }
  ...
}

throws AppError | _ keeps AppError named and lets _ absorb the body's inferred throws. It stays sound: callers see the full union (AppError | JsonParseError | JsonDecodeError), because the firewall no longer treats a hole-bearing clause as a closed contract — the call-graph merge still runs and the hole is filled from the body's effective throw set. Plain throws AppError (no _) remains exhaustive.

Separately, the stdlib imprecision the ticket described has shifted since it was filed: baml.json.stringify is already throws never; the real culprit was baml.json.to_json / serialize declaring a spurious | JsonParseError on a pure serializer. The native impl (render_to_json_honoring_overrides) only ever raises JsonSerializationError, so those declarations are corrected.

Implementation notes

  • _ lowers to a single compile-time-only Ty::Infer hole (#[axis(tir)]); it never reaches RuntimeTy/RealizedTy/MIR.
  • B-230 fill: at a let with a hole-bearing annotation, the initializer is inferred freely (pushing the hole down as an expected type would poison the array/map container), the annotation's holes are filled from the actual type, then re-checked against the filled type so non-hole positions are still validated element-wise.
  • B-247 open slot: detected at the throws firewall (throw_inference.rs), the callable-type builder (callable.rs, fills the hole from the body's effective throws so callers see the union), and the declared-vs-effective check (builder.rs).
  • Using _ where it cannot be inferred (parameter / return / field / alias types) is a clean error (E0147) rather than a compiler panic.

Testing

  • New regression suite crates/baml_tests/tests/wildcard_type_inference.rs (12 tests): B-230 inference + precision + wrong-arg rejection + general type-arg holes; B-247 open-slot + caller soundness + exhaustive-default + stdlib precision; E0147 rejection in bad positions.
  • Full baml_tests suite green; clippy clean; stdlib TIR snapshot updated for the to_json/serialize throws change.

Note for reviewers

This adds a small piece of surface to the documented type system (TYPE_SYSTEM.md updated for both _ cases). @2kai2kai2 — the throws T | _ open-slot semantics and the _ type-arg hole touch your type-system design; would value your eye on whether the wording/semantics match your intent.

Summary by CodeRabbit

  • New Features

    • Expanded _ wildcard inference for generic type arguments and improved _ support in throws-clause typing, including “partial/open” throws contracts.
    • Inference placeholders now display consistently as _ across type rendering.
  • Bug Fixes

    • Tightened JSON conversion helper error contracts: to_json and serialize now expose only JsonSerializationError.
    • Improved enforcement and diagnostics for invalid _ usage in non-inferable positions (E0147).
  • Tests

    • Added regression tests for _ inference, open vs closed throws behavior, and JSON serialization rules.
  • Documentation

    • Updated BAML type-system guidance for partial throws typing and _ inference holes.

…-230, B-247)

Adds a `_` inference-hole wildcard usable as a generic type argument and as a
`throws`-clause member, and corrects the stdlib `to_json`/`serialize` throw sets.

B-230: `Future<int, _>` now infers the error type from the spawn body's
transitive throws while keeping the value type explicit, instead of forcing the
developer to spell out the full error union or fall into the `never` trap. The
hole is filled from the initializer's actual type, so it stays exact (not erased
to `unknown`) and `await` sees the precise error type.

B-247: `throws T | _` is an opt-in open contract — `_` absorbs the body's
inferred (e.g. stdlib) throws while `T` stays named, and callers still see the
full union (sound). Plain `throws T` stays exhaustive. Also drops the spurious
`JsonParseError` from `baml.json.to_json`/`serialize` (a pure serializer cannot
raise a parse error).

`_` lowers to a single compile-time-only `Ty::Infer` hole that never reaches
runtime; using it where it cannot be inferred (parameter/return/field/alias
types) is a clean error (E0147) rather than a compiler panic.
@linear

linear Bot commented Jun 29, 2026

Copy link
Copy Markdown

B-230

B-247

@vercel

vercel Bot commented Jun 29, 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 Jun 29, 2026 8:20pm
promptfiddle Ready Ready Preview, Comment Jun 29, 2026 8:20pm
promptfiddle2 Ready Ready Preview, Comment Jun 29, 2026 8:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a94aa2b4-67a1-4f5c-883b-4360b1629437

📥 Commits

Reviewing files that changed from the base of the PR and between 0b64f76 and 8d04f1a.

📒 Files selected for processing (2)
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_tests/tests/wildcard_type_inference.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • baml_language/crates/baml_tests/tests/wildcard_type_inference.rs

📝 Walkthrough

Walkthrough

Adds _ inference holes across BAML type lowering, checking, and throws propagation. Updates stdlib JSON throw signatures, documentation, rendering, diagnostics, and regression tests for generic arguments, non-inferable positions, and open throws X | _ contracts.

Changes

Wildcard _ Inference Hole

Layer / File(s) Summary
Core _ type shapes and rendering
baml_language/crates/baml_type/src/family.rs, baml_language/crates/baml_type/src/lib.rs, baml_language/crates/baml_type/src/normalize.rs, baml_language/crates/baml_type/src/runtime_ty.rs, baml_language/crates/baml_compiler2_ast/src/ast.rs, baml_language/crates/baml_compiler2_tir/src/lower_type_expr.rs, baml_language/crates/baml_compiler2_tir/src/normalize.rs, baml_language/crates/baml_compiler2_tir/src/exhaustiveness.rs, baml_language/crates/baml_compiler2_tir/src/interfaces/coherence.rs, baml_language/crates/baml_compiler2_ppir/src/ty.rs, baml_language/crates/baml_compiler2_hir/src/builder.rs, baml_language/crates/baml_lsp2_actions/src/check.rs, baml_language/crates/baml_lsp2_actions/src/utils.rs, baml_language/crates/baml_project/src/client_codegen.rs, baml_language/crates/baml_builtins2_codegen/src/extract.rs, baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
Adds Ty::Infer and TypeExprKind::Infer, renders _, normalizes inference holes as non-runtime or unreachable in structural views, rejects them at runtime boundaries, and updates related lowering, coherence, PPIR, HIR/LSP, codegen, and snapshot rendering paths.
Wildcard position validation and diagnostics
baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs, baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs, baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs, baml_language/crates/baml_compiler2_ast/src/lower_cst.rs, baml_language/crates/baml_compiler2_ast/src/lib.rs, baml_language/TYPE_SYSTEM.md
Maps "_" to TypeExprKind::Infer, adds recursive wildcard validation, introduces WildcardTypeNotAllowed / E0147, applies the validator across non-inferable type positions, and documents partial throws and generic inference-hole rules.
Hole filling during type checking
baml_language/crates/baml_compiler2_tir/src/builder.rs
Adds recursive hole detection and filling helpers, then uses them for expected-type checking, let binding bound types, unknown-like classification, resolution-failure handling, and ascription resolution.
**Open `throws X _propagation** <br>baml_language/crates/baml_compiler2_tir/src/throw_inference.rs, baml_language/crates/baml_compiler2_tir/src/callable.rs, baml_language/crates/baml_compiler2_tir/src/builder.rs, baml_language/crates/baml_builtins2/baml_std/baml/ns_json/json.baml, baml_language/crates/baml_tests/tests/wildcard_type_inference.rs`

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • BoundaryML/baml#3866: Changes the same baml_compiler2_ast::TypeExpr/TypeExprKind representation foundation that this PR extends with TypeExprKind::Infer.

Poem

🐇 _ hopped in, soft and bright,
Into types by day and throws by night.
Named facts stay put, holes fill tight,
And E0147 keeps the wildcards right.
JSON hums with a slimmer cast,
While rabbit feet stamp “inferred!” at last.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding _ wildcard inference in type-argument and throws positions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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/type-checker

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.

@vercel
vercel Bot temporarily deployed to Preview – beps June 29, 2026 15:06 Inactive
@github-actions

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.

@antoniosarosi
antoniosarosi requested a review from 2kai2kai2 June 29, 2026 15:07
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 29, 2026 15:11 Inactive
@github-actions

github-actions Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 21.6 MB 9.2 MB file 21.5 MB +32.2 KB (+0.1%) OK
✅ packed-program Linux 🔒 15.6 MB 6.6 MB file 15.6 MB +14.9 KB (+0.1%) OK
✅ baml-cli macOS 🔒 16.6 MB 8.0 MB file 16.6 MB +33.1 KB (+0.2%) OK
✅ packed-program macOS 🔒 12.1 MB 5.8 MB file 12.1 MB +16 B (+0.0%) OK
✅ baml-cli Windows 🔒 18.2 MB 8.2 MB file 18.1 MB +36.4 KB (+0.2%) OK
✅ packed-program Windows 🔒 13.0 MB 5.8 MB file 13.0 MB +10.8 KB (+0.1%) OK
✅ bridge_wasm WASM 14.3 MB 🔒 4.1 MB gzip 4.1 MB +8.1 KB (+0.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

@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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler2_ast/src/lower_cst.rs (1)

1311-1388: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate _ in interface requires clauses too.

This adds E0147 coverage for interface fields, but lower_interface still lowers requires parents without check_wildcard_type(...). That lets unsupported cases like interface I requires _ or requires Foo<_> slip past the new wildcard restriction.

Suggested fix
     let requires: Vec<TypeExpr> = parent_type_nodes
         .into_iter()
         .map(|te| {
             let expr = lower_type_expr::lower_type_expr_node(&te);
             let te_span = te.syntax().span_range();
-            check_unknown_type(
-                &expr,
-                format!("requires clause of interface `{iface_name}`"),
-                te_span,
-                diags,
-            );
+            let context = format!("requires clause of interface `{iface_name}`");
+            check_unknown_type(&expr, context.clone(), te_span, diags);
+            lower_type_expr::check_wildcard_type(&expr, &context, te_span, diags);
             expr.with_span(te_span)
         })
         .collect();
🤖 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_compiler2_ast/src/lower_cst.rs` around lines 1311 -
1388, `lower_interface` is missing wildcard validation in its `requires` clause
lowering, so `_` can still be accepted there even though interface fields now
reject it. Update the `requires` parent handling in `lower_interface` to run
`check_wildcard_type(...)` on each lowered parent `TypeExpr`, alongside the
existing `check_unknown_type(...)`, using the same span/context so cases like
`interface I requires _` and `requires Foo<_>` are reported consistently.
🧹 Nitpick comments (1)
baml_language/crates/baml_tests/tests/wildcard_type_inference.rs (1)

187-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a serialize<T> throws regression too.

This locks down baml.json.to_json, but the stdlib change in baml_language/crates/baml_builtins2/baml_std/baml/ns_json/json.baml:177 narrows serialize<T> in the same way. A small compile-pass test for throws baml.json.JsonSerializationError would keep that wrapper from drifting independently.

Suggested follow-up test
+/// `baml.json.serialize` should also expose only `JsonSerializationError`.
+#[tokio::test]
+async fn json_serialize_is_serialize_only() {
+    let out = baml_test!(
+        r#"
+        function dump(x: int) -> string throws baml.json.JsonSerializationError {
+          baml.json.serialize(x)
+        }
+        function main() -> int { 0 }
+    "#
+    );
+    assert!(
+        matches!(out.result, Ok(BexExternalValue::Int(0))),
+        "expected 0, got {:?}",
+        out.result
+    );
+}
🤖 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/tests/wildcard_type_inference.rs` around
lines 187 - 205, Add a regression test for the `serialize<T>` wrapper in the
same test module, alongside `json_to_json_is_serialize_only`, to verify it can
declare exactly `throws baml.json.JsonSerializationError` without triggering an
exhaustiveness issue. Reuse the existing `baml_test!` pattern and reference the
`serialize` path in the stdlib wrapper so the compile-pass coverage matches the
`baml.json.to_json` case and prevents the two implementations from drifting
independently.
🤖 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.

Inline comments:
In `@baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs`:
- Around line 609-671: Reject wildcard `_` in interface requires clauses and
generic bounds by invoking check_wildcard_type from the lowering paths in
lower_cst.rs that build those TypeExprs, including the requires parent handling
and the T extends bound handling. Ensure the new validation catches
TypeExprKind::Infer in those locations and emits the existing
LoweringDiagnostic::WildcardTypeNotAllowed with the right context, consistent
with the checks already used in lower_type_expr::check_wildcard_type.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 6834-6850: The infer-hole fallback in builder.rs is emitting
duplicate diagnostics because the speculative infer_expr probe and the
subsequent check_expr both append the same initializer errors. Update this
branch in the check path to snapshot the current diagnostic count before calling
infer_expr, then truncate any probe-generated diagnostics before running the
re-check with the filled type, matching the speculative handling used elsewhere
in this file. Keep the fix localized around the infer-hole fallback and the
related InferContext::report* accumulation behavior.
- Around line 8384-8455: `fill_infer_holes` is missing support for the same type
shapes that `ty_has_infer_hole` treats as containing holes, so `Ty::Union` and
`Ty::Function` can keep unresolved `Ty::Infer` values. Update `fill_infer_holes`
in `builder.rs` to recursively rewrite those shapes the same way it already
handles `Ty::Future`, `Ty::List`, `Ty::Map`, `Ty::Class`, and `Ty::Interface`,
or else narrow `ty_has_infer_hole` so it only reports cases this helper can
actually fill. Keep the behavior consistent between the two helpers and ensure
no `Ty::Infer` survives in the resolved type.

In `@baml_language/crates/baml_compiler2_tir/src/throw_inference.rs`:
- Around line 663-669: The `throws` lowering path is missing wildcard validation
for nested generic types, so `throws Wrapper<_>` can slip through as closed
while still containing an inferred hole. Update the `throws` handling in
`throw_inference.rs` to run `check_wildcard_type` before lowering, and make
`throws_ty_has_infer_hole` recursively inspect nested type structure beyond only
top-level `Ty::Infer` and `Ty::Union` so embedded infer holes are detected
consistently.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler2_ast/src/lower_cst.rs`:
- Around line 1311-1388: `lower_interface` is missing wildcard validation in its
`requires` clause lowering, so `_` can still be accepted there even though
interface fields now reject it. Update the `requires` parent handling in
`lower_interface` to run `check_wildcard_type(...)` on each lowered parent
`TypeExpr`, alongside the existing `check_unknown_type(...)`, using the same
span/context so cases like `interface I requires _` and `requires Foo<_>` are
reported consistently.

---

Nitpick comments:
In `@baml_language/crates/baml_tests/tests/wildcard_type_inference.rs`:
- Around line 187-205: Add a regression test for the `serialize<T>` wrapper in
the same test module, alongside `json_to_json_is_serialize_only`, to verify it
can declare exactly `throws baml.json.JsonSerializationError` without triggering
an exhaustiveness issue. Reuse the existing `baml_test!` pattern and reference
the `serialize` path in the stdlib wrapper so the compile-pass coverage matches
the `baml.json.to_json` case and prevents the two implementations from drifting
independently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 940047df-0103-4986-a162-ccc735c991eb

📥 Commits

Reviewing files that changed from the base of the PR and between 141cd9f and 3e95ab3.

⛔ Files ignored due to path filters (1)
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snap is excluded by !**/*.snap
📒 Files selected for processing (27)
  • baml_language/TYPE_SYSTEM.md
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_json/json.baml
  • baml_language/crates/baml_builtins2_codegen/src/extract.rs
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/lib.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_ppir/src/ty.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/callable.rs
  • baml_language/crates/baml_compiler2_tir/src/exhaustiveness.rs
  • baml_language/crates/baml_compiler2_tir/src/interfaces/coherence.rs
  • baml_language/crates/baml_compiler2_tir/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_tir/src/normalize.rs
  • baml_language/crates/baml_compiler2_tir/src/throw_inference.rs
  • baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_lsp2_actions/src/utils.rs
  • baml_language/crates/baml_project/src/client_codegen.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
  • baml_language/crates/baml_tests/tests/wildcard_type_inference.rs
  • baml_language/crates/baml_type/src/family.rs
  • baml_language/crates/baml_type/src/lib.rs
  • baml_language/crates/baml_type/src/normalize.rs
  • baml_language/crates/baml_type/src/runtime_ty.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 29, 2026 15:24 Inactive
…iagnostics

Addresses the CodeRabbit review on #3887:

- Reject `_` in interface `requires` clauses and generic bounds (`<T extends _>`),
  matching the existing E0147 gate for parameter/return/field/alias types.
- Reject a `_` nested inside a thrown type (`throws Err<_>`); only a top-level
  `_` union member is the open-contract marker (`throws AppError | _`).
- Add a safety net so a `_` that `fill_infer_holes` cannot structurally align
  (a union member, a function component, or a shape mismatch — e.g.
  `Box<int | _>`, `() -> _`) becomes a clean `unresolved type: _` error instead
  of a compiler panic at runtime lowering. The pattern ascription is the single
  reporting site, so the error is emitted exactly once.
- Roll back the speculative free-inference probe's diagnostics in the infer-hole
  `let` path (`diagnostic_count`/`truncate_diagnostics`) so an ill-formed
  initializer is reported once, not twice.

Adds regression tests for each case.
@vercel
vercel Bot temporarily deployed to Preview – beps June 29, 2026 16:15 Inactive

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler2_ast/src/lower_cst.rs (1)

1542-1546: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Generic-param bounds in lower_interface and lower_method_sig still skip the wildcard check.
lower_function and lower_class already reject T extends _, but these two lowering paths collect generic_param_bounds without running check_wildcard_type, so _ can still fall through the generic unresolved-type path. Add the same loop after each bound collection.

🤖 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_compiler2_ast/src/lower_cst.rs` around lines 1542 -
1546, The generic parameter bound handling in lower_interface and
lower_method_sig is missing the wildcard validation that lower_function and
lower_class already perform. After collecting generic_param_bounds in those
paths, run the same check_wildcard_type loop on each bound using the existing
parameter_context so that T extends _ is rejected consistently. Update the logic
near generic_param_bounds in lower_interface and lower_method_sig to mirror the
validation already present in the other lowering functions.
🤖 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.

Outside diff comments:
In `@baml_language/crates/baml_compiler2_ast/src/lower_cst.rs`:
- Around line 1542-1546: The generic parameter bound handling in lower_interface
and lower_method_sig is missing the wildcard validation that lower_function and
lower_class already perform. After collecting generic_param_bounds in those
paths, run the same check_wildcard_type loop on each bound using the existing
parameter_context so that T extends _ is rejected consistently. Update the logic
near generic_param_bounds in lower_interface and lower_method_sig to mirror the
validation already present in the other lowering functions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: faa608ab-6c1b-48c7-a848-72f405703100

📥 Commits

Reviewing files that changed from the base of the PR and between 3e95ab3 and 2b900d8.

📒 Files selected for processing (4)
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_tests/tests/wildcard_type_inference.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_tests/tests/wildcard_type_inference.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 29, 2026 16:21 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 29, 2026 16:33 Inactive
… (Kai review)

Kai noted that mapping `Ty::Infer` to a "matches-anything" sentinel during
normalization breaks equivalence transitivity — `Box<int> == Box<_>` and
`Box<string> == Box<_>` would both hold, transitively (and falsely) equating
`Box<int>` with `Box<string>`. There is no sound normal form for an inference
hole (Unknown *and* Error break transitivity identically), so the only correct
design is to guarantee a hole never reaches normalization.

Enforcing that immediately surfaced the leak Kai suspected: a `_` in a signature
position (`-> _`, `(x: _)`, field/alias/bound/`requires` types) was E0147-reported
but still lowered to `Ty::Infer` and reached the body-vs-return subtype check.

- A `Ty::Infer` reaching `baml_type::normalize` / `compiler2_tir::normalize` is
  now an `unreachable!` — a hole there is a compiler bug, not a case to tolerate.
- The AST wildcard gate now *rewrites* an illegal `_` to `TypeExprKind::Error`
  (in addition to reporting E0147), so signature/field/alias/bound/`requires`
  positions lower to the error-recovery `Ty::Unknown` sentinel, never `Ty::Infer`.
- `Ty::Infer` now originates only from the two contexts that resolve it before
  any check: `let`-binding ascriptions (filled from the initializer) and
  top-level `throws` members (flattened by the firewall). The full suite passing
  with the `unreachable!` in place validates the invariant.

Also extends `_` rejection to interface and method generic bounds.
@antoniosarosi

Copy link
Copy Markdown
Contributor Author

Addressing @2kai2kai2's normalization-soundness feedback

Kai raised (offline) that mapping Ty::Infer to a "matches-anything" sentinel during normalization breaks equivalence transitivity — Box<int> == Box<_> and Box<string> == Box<_> would both hold, falsely implying Box<int> == Box<string> — and that there's no guarantee every _ is filled before checking, since we use hole-capable Ty rather than RuntimeTy.

He's right, and there is no sound normal form for a hole (Unknown and Error break transitivity identically), so I enforced the invariant rather than papering over it:

  • A Ty::Infer reaching baml_type::normalize / compiler2_tir::normalize is now an unreachable!. That immediately caught a real leak: a _ in a signature position (-> _, params, fields, bounds, aliases, requires) was reported as E0147 but still lowered to Ty::Infer and reached the body-vs-return-type check.
  • The AST wildcard gate now rewrites every illegal _ to an error sentinel (alongside the E0147), so those positions lower to the error-recovery Ty::Unknown, never Ty::Infer.
  • After this, Ty::Infer only ever originates from the two contexts that resolve it before any check: let-binding ascriptions (filled from the initializer) and top-level throws members (flattened by the firewall). The full suite passing with the unreachable! in place is the proof the "inference before checking" invariant holds.

Agreed that moving the checking path to RuntimeTy would turn this into a compile-time guarantee rather than a runtime-enforced one — that's a larger refactor I'd prefer to do separately; happy to file a follow-up. Does the unreachable!-enforced invariant work for you in the meantime?

@vercel
vercel Bot temporarily deployed to Preview – beps June 29, 2026 19:48 Inactive

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@baml_language/crates/baml_compiler2_ast/src/lower_cst.rs`:
- Around line 1584-1588: In lower_method_sig, required interface method
signatures must not allow a top-level `_` in the throws clause because there is
no body to infer from. Update the throws handling in lower_cst.rs to reject
wildcard throws for sig.throws_clause() by using check_wildcard_type (or
equivalent concrete-type validation) instead of check_throws_wildcard, and keep
the lowering path in lower_type_expr::lower_type_expr_node and
expr.with_span(te_span) for valid explicit throws types.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f38f0716-1ff6-4d3f-a72b-078d32fc4170

📥 Commits

Reviewing files that changed from the base of the PR and between 2b900d8 and 0b64f76.

📒 Files selected for processing (4)
  • baml_language/crates/baml_compiler2_ast/src/lower_cst.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs
  • baml_language/crates/baml_compiler2_tir/src/normalize.rs
  • baml_language/crates/baml_type/src/normalize.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • baml_language/crates/baml_compiler2_ast/src/lower_type_expr.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 29, 2026 19:53 Inactive
A bodyless method signature (interface required method) has no body to infer an
open `throws … | _` from, and its declared throws is compared structurally
during conformance checking — so a top-level `_` there would leak `Ty::Infer`
into normalization (the new `unreachable!` guard). Reject any `_` in a method
signature's throws clause with E0147, like other non-inferable positions. Adds a
regression test.
@vercel
vercel Bot temporarily deployed to Preview – beps June 29, 2026 20:02 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 29, 2026 20:08 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 29, 2026 20:20 Inactive
@antoniosarosi
antoniosarosi added this pull request to the merge queue Jun 30, 2026
Merged via the queue into canary with commit b88c5a1 Jun 30, 2026
88 of 90 checks passed
@antoniosarosi
antoniosarosi deleted the antonio/type-checker branch June 30, 2026 14:11
meefs pushed a commit to meefs/baml that referenced this pull request Jul 7, 2026
…aryML#3910)

## Summary

Extends the `_` wildcard feature from BoundaryML#3887 (B-230/B-247) to the
**expression-context** type positions. Before this, `_` was solvable in
a `let` annotation and a `throws` clause, but writing it anywhere in an
expression (a call turbofish, an object literal, a generic value, an
upcast) either panicked the compiler or was silently un-handled.

cc @antoniosarosi, since this builds directly on your `_` inference work
and changes the invariant your firewall documented.

## Root cause

Your `check_wildcard_type` firewall rewrites `_` to `Ty::Error` at every
**declaration** site (params, returns, fields, aliases, bounds), which
is why the doc there said "a `Ty::Infer` only ever originates from the
two hole-handling contexts."

That invariant was actually already violated: there are **four
expression positions that carry a written type and never pass through
the firewall**, so a `_` there reached type checking as a live
`Ty::Infer`:

1. call turbofish, `foo<T, _>(x)`
2. object construction, `Box<_> { ... }`
3. generic-apply value, `id<_>`
4. upcast target, `c.as<Show<_>>`

A surviving `Ty::Infer` then tripped an `unreachable!` at one of the two
boundaries that deliberately refuse to guess a meaning for a hole:

- `baml_type/src/runtime_ty.rs` (MIR runtime lowering): `` `Infer` is
not a valid `RuntimeTy` ``
- `baml_compiler2_tir/src/normalize.rs:566` (structural normalization):
`inference hole `_` reached structural normalization`

These were latent panics, not something a user had to do anything exotic
to hit.

## Approach

Same principle as your `let`/`throws` handling: fill the hole from a
local source where one exists, reject it cleanly where none does. Never
let a `Ty::Infer` escape the site.

| Position | Fill source | Behavior |
|---|---|---|
| Call turbofish `race<T, _>(...)` | the call arguments | **solved** |
| Object `Box<_> { v: 5 }` | the field values | **solved** |
| Generic-apply value `id<_>` | none (a bare value has nothing to infer
from) | clean diagnostic |
| Upcast `c.as<Show<_>>` | none (explicit ascription) | clean diagnostic
|

For the two solve sites, a hole that still cannot be resolved from its
source (e.g. `pick<int, _>(5)` where `U` is in neither an argument nor
the return) is reported as `cannot infer type parameter`, not accepted
or panicked.

I deliberately left upcast as a **reject** rather than solving `_` from
the receiver's `implements` list. That is a bigger feature and matches
how `is Show<_>` already behaves. Happy to revisit if you think upcast
should solve.

## Before / after

**1. Call turbofish (the original report)**

```baml
function with_timeout<T>(millis: int, f: () -> T) -> T {
    let work = spawn { f() };
    let timer = spawn {
        baml.sys.sleep(baml.time.Duration.from_milliseconds(millis));
        work.cancel();
        throw TimeoutError { message: `timed out` };
    };
    let result = baml.future.race<T, _>([work, timer]);
    await result
}
```
- Before: panic, `` `Infer` is not a valid `RuntimeTy` `` (runtime
lowering).
- After: compiles and runs. `_` (the future's error type) is inferred
from `[work, timer]`, while `T` stays pinned. Note the no-turbofish form
`race([work, timer])` cannot type-check here (the two futures do not
unify), which is exactly why the partial `<T, _>` is needed.

**2. Object construction**

```baml
class Box<T> { v T }
let b = Box<_> { v: 5 }        // was: panic (normalize.rs:566)  ->  now: T = int, runs

class Pair<A, B> { a A  b B }
let p = Pair<int, _> { a: 1, b: "hi" }   // pins A = int, infers B = string from the field
```

**3. Generic-apply value (no source, reject)**

```baml
function id<T>(x: T) -> T { x }
let f = id<_>                  // was: panic (normalize.rs:566)  ->  now: E0002 cannot infer type parameter `T`
```

**4. Upcast target (no source, reject)**

```baml
c.as<Show<_>>                  // was: panic (normalize.rs:566)  ->  now: E0002 unresolved type: _  (matches `is Show<_>`)
```

## Centralized policy (so a 5th position does not reintroduce this)

The enforcement is necessarily per-site (a hole has no sound normal
form, so it must be resolved before that site's own subtype/normalize
checks; the `unreachable!` is the intentional tripwire). To avoid four
ad-hoc copies, the fill-or-reject rule now lives in one documented place
in `builder.rs`:

- `report_uninferable_type_hole(param, at)`: the single
reject-and-neutralize path. `CannotInferTypeParameter` when a parameter
is named, `UnresolvedType` for a nested ascription hole.
- `fill_expression_type_arg_hole(partial, candidate, param, at)`:
keep-if-concrete, else fill from the site's inferred candidate, else
reject. `candidate: None` degenerates to pure reject, so the same helper
serves both solve and reject sites.

Call, generic-apply, and upcast all route through these. Object keeps
its own field-inference loop (it has a richer policy: an unconstrained
*phantom* param recovers to `BuiltinUnknown` instead of erroring) but is
documented as the field-sourced case of the same rule.

On the MIR side, the two hole detectors were merged into one
(`type_arg_is_infer_hole`), which asks the actual conversion boundary
(`lower_to_runtime(..).is_err()`) so it can never drift from what the
runtime accepts.

I also corrected the `check_wildcard_type` doc, which claimed the
two-context invariant that is no longer true.

## What "handled" means here

Every `_` position now either solves or produces a clean diagnostic. It
does **not** mean every theoretically-inferable `_` is inferred (upcast
is a deliberate reject). The guarantee is: no `Ty::Infer` reaches
normalization or runtime lowering from a supported syntactic position.

## Files

- `baml_compiler2_tir/src/builder.rs`: policy helpers + the four sites
(call, object, generic-apply, upcast).
- `baml_compiler2_mir/src/lower.rs`: substitute the TIR-solved type into
hole slots for call and object type-args instead of re-lowering the raw
`_`; single hole detector.
- `baml_compiler2_ast/src/lower_type_expr.rs`: corrected
`check_wildcard_type` invariant docs.
- `baml_tests/tests/wildcard_expression_holes.rs`: 9 regression tests
covering all four positions (solve, mixed-partial, nested, phantom, and
each reject).

## Testing

New file green (9). No regressions across wildcard (17), interfaces (35
/ 150), instantiation, spawn, match/typevar, json interfaces (12 / 11),
and tir/mir unit (175). Clean `cargo clippy` / `cargo fmt`.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* `_` type holes in expression-contexts are now handled more
consistently, producing clear compile-time errors instead of runtime
panics.
* Improved inference for partial/explicit generic type arguments,
including mixed inferred and written holes.
* Object construction, upcast/ascription targets, and call-site generics
now resolve `_` reliably using available context.
* **Tests**
* Added regression coverage for wildcard holes across function calls,
timeouts/race-style expressions, nested constructors, and interface
upcasts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 8d04f1ad Deployed Jun 29, 2026 by vercel[bot]
Preview – promptfiddle2 — 8d04f1ad Deployed Jun 29, 2026 by vercel[bot]
Preview – beps — 8d04f1ad Deployed Jun 29, 2026 by vercel[bot]
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