Skip to content

bridge-cpp: order-canonical baml::Union, baml::match, typed error unions - #4071

Merged
codeshaunted merged 10 commits into
canaryfrom
avery/cpp-unions
Jul 17, 2026
Merged

bridge-cpp: order-canonical baml::Union, baml::match, typed error unions#4071
codeshaunted merged 10 commits into
canaryfrom
avery/cpp-unions

Conversation

@codeshaunted

@codeshaunted codeshaunted commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

BAML unions are sets (string | int == int | string), but std::variant<A, B> and std::variant<B, A> are distinct C++ types. This closes the gap and re-lands unions in the C++ bridge (bridge-week step 7, parked at the #4004 slim pending representation work).

baml::Union

A std::variant alias (not a wrapper class) whose alternatives are sorted at compile time by a constexpr per-type name, so every spelling of one alternative set resolves to the same instantiation:

static_assert(std::is_same_v<baml::Union<int64_t, std::string>,
                             baml::Union<std::string, int64_t>>);

Because it IS a std::variant, std::get, std::holds_alternative, and std::visit all work unchanged. baml::Match(u, arms...) is the reading companion: one callable per alternative, dispatched by type, exhaustiveness enforced by std::visit at compile time, const auto& as the explicit catch-all.

Nullability stays std vocabulary: T | null is std::optional<T>, A | B | null is std::optional<Union<A, B>>; null is never an alternative.

Codec

Encode visits the active alternative (no union wrapper inbound; engine/Python parity). Decode receives the union-unwrapped value and picks an alternative from the wire arm alone — canonical ordering means selection must be (and is) order-independent: strict arm matching first (an int wire value never satisfies a double alternative), the engine's int→float coercion admitted in a second pass only when no int arm matched. Enum decode now FQN-checks like classes so two enums in one union dispatch precisely.

Typed error unions

Generated bindings pass the declared throws set to CallSync as a Union; the error arm decodes into it and throws BamlThrown<Union<...>> carrying the typed payload:

try {
  baml_sdk::raises_test::LoadDoc("x");  // throws ParseError | TimeoutError
} catch (const baml::BamlThrown<baml::Union<TimeoutError, ParseError>>& e) {
  // reversed spelling on purpose - same catchable type
  baml::Match(e.value,
      [](const ParseError& p) { ... },
      [](const TimeoutError& t) { ... });
}

BamlThrown derives BamlError, so untyped catch sites and is<T>()/get<T>() keep working; a thrown value outside the declared set falls back to plain BamlError.

Un-skipped by this

RecList/RecListWithOther recursive aliases, baml.json.json (ParseJson), ComplexProfile/Invoice, the unions fixture namespace, and the baml.panics.Panic alias all emit again.

Tests

Full ports of test_unions.py, test_complex_models.py, the RecList cases of test_aliases.py, and the union subset of test_errors.py (typed catch + match + class_name parity for single-vs-multi-member throws + untyped compatibility), plus C++-only canonicalization static_asserts and std::variant-interop cases. type_shapes: 77 cases, function_calls: 11; fixture suite 10/10; bridge smoke green.

Summary by CodeRabbit

  • New Features
    • Added order-canonical C++ union support via baml::Union plus type-based baml::match.
    • Enabled typed “throws” decoding so thrown errors can carry a strongly-typed union payload and preserve error metadata.
    • Updated SDK generation to support multi-member unions and typed throws; improved generated “Raises” doc coverage.
  • Bug Fixes
    • Tightened enum decoding validation and improved union decoding/mismatch handling (including controlled numeric coercion behavior).
  • Tests / Documentation
    • Expanded C++ SDK round-trip and compile-time union/alias/optional/list/forward-ref tests; updated C++ style guidance and clarified the public unset tag (Unset).

BAML unions are sets (string | int == int | string), but variant<A, B>
and variant<B, A> are distinct C++ types. baml::Union<Ts...> closes the
gap: a std::variant ALIAS whose alternatives are sorted at compile time
by a constexpr per-type name, so every spelling of one alternative set
resolves to the same instantiation. It stays a plain std::variant
(std::get/visit/holds_alternative all work); baml::match(u, arms...)
is the reading companion (type-dispatched, exhaustiveness-checked by
std::visit, const auto& as the explicit catch-all).

- emitter re-enables multi-member unions as ::baml::Union<...>;
  T | null stays std::optional<T> and A | B | null stays
  optional<Union<A, B>> (null is never an alternative)
- Codec<std::variant<Ts...>>: encode visits the active alternative (no
  union wrapper inbound); decode is order-independent by construction -
  strict arm matching first (int never satisfies a double alternative),
  engine int->float coercion admitted in a second pass only when no int
  arm matched; enum decode now FQN-checks like classes so two enums in
  one union dispatch precisely
- typed error unions: generated bindings pass the declared throws set
  to CallSync as a Union; the error arm decodes into it and throws
  BamlThrown<Union<...>> (derives BamlError, so untyped catch sites and
  is<T>/get<T> keep working; undeclared throws fall back untyped).
  BamlThrown<Union<A, B>> and BamlThrown<Union<B, A>> are the same
  catchable type
- un-skipped by this: RecList/RecListWithOther recursive aliases,
  baml.json.json (ParseJson), ComplexProfile/Invoice, unions fixture
  namespace, baml.panics.Panic alias
- tests: full port of test_unions.py + test_complex_models.py + the
  RecList cases of test_aliases.py + the union subset of test_errors.py
  (typed catch + match + class_name parity + untyped compatibility),
  plus C++-only canonicalization static_asserts and std::variant-interop
  cases; type_shapes 77 cases, function_calls 11, suite 10/10
@vercel

vercel Bot commented Jul 17, 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 17, 2026 8:32pm
promptfiddle Ready Ready Preview, Comment Jul 17, 2026 8:32pm
promptfiddle2 Ready Ready Preview, Comment Jul 17, 2026 8:32pm

Request Review

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

@coderabbitai

coderabbitai Bot commented Jul 17, 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: cf5d4799-65e0-4809-892d-4b70da86e107

📥 Commits

Reviewing files that changed from the base of the PR and between b70cf85 and 7c689fc.

📒 Files selected for processing (2)
  • baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/unions_static.cc
  • baml_language/sdks/cpp/bridge_cpp/include/baml/union.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/sdks/cpp/bridge_cpp/include/baml/union.h
  • baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/unions_static.cc

📝 Walkthrough

Walkthrough

The PR adds canonical C++ union types, variant serialization, typed declared-error propagation, generator support for union and throws signatures, stricter enum decoding, and tests covering unions, aliases, complex models, and typed errors.

Changes

C++ union and typed-error support

Layer / File(s) Summary
Canonical union runtime
baml_language/sdks/cpp/bridge_cpp/include/baml/union.h, baml_language/sdks/cpp/bridge_cpp/include/baml/baml.h, baml_language/sdks/cpp/bridge_cpp/include/baml/arg.h, baml_language/sdks/cpp/STYLE.md
Defines canonical baml::Union, baml::match, umbrella-header access, and updated unset and naming conventions.
Union codecs and typed error dispatch
baml_language/sdks/cpp/bridge_cpp/include/baml/codec.h, baml_language/sdks/cpp/bridge_cpp/include/baml/errors.h, baml_language/sdks/cpp/bridge_cpp/include/baml/detail/call.h
Encodes and decodes variants, introduces BamlThrown, and passes declared throws types through result decoding.
Generated union and throws bindings
baml_language/sdks/cpp/sdkgen_cpp/src/lib.rs
Embeds the runtime header, emits union types, wires typed CallSync arguments, and validates enum names.
Union and error coverage
baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/*, baml_language/sdk_tests/crates/cpp/function_calls/customizable/tests/*
Tests canonical unions, optional and recursive types, containers, complex model round trips, typed errors, and generated Raises documentation.

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

Sequence Diagram(s)

sequenceDiagram
  participant GeneratedFunction
  participant CallSync
  participant DecodeResult
  participant VariantCodec
  participant BamlThrown
  GeneratedFunction->>CallSync: call with return and declared throws types
  CallSync->>DecodeResult: decode response envelope
  DecodeResult->>VariantCodec: decode declared thrown union payload
  VariantCodec-->>DecodeResult: typed union value
  DecodeResult->>BamlThrown: throw typed error with metadata and payload
Loading

Possibly related PRs

  • BoundaryML/baml#3798: Both changes cover multi-member throws unions and preservation of thrown error class names.

Poem

A rabbit packed unions neat,
With typed errors quick and sweet.
Variants hop in ordered rows,
Round trips bloom where codec flows.
“BAML!” I cheer, with ears held high—
New tests sparkle through the sky.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 bridge-cpp changes: canonical unions, match dispatch, and typed error-union support.
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 avery/cpp-unions

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.

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/sdks/cpp/bridge_cpp/include/baml/union.h`:
- Around line 46-76: Update the canonicalization flow centered on
detail::CanonSort and Union to remove duplicate alternative types before forming
the std::variant. Ensure repeated types such as Union<int64_t, int64_t> produce
the same variant type as Union<int64_t>, while preserving the existing
name-based canonical ordering for distinct alternatives and avoiding ambiguous
type-based construction or access.
🪄 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: f3eb8616-b1c2-4a19-8234-b8957be62f9e

📥 Commits

Reviewing files that changed from the base of the PR and between a5690a2 and d10ff43.

📒 Files selected for processing (10)
  • baml_language/sdk_tests/crates/cpp/function_calls/customizable/tests/test_errors.cc
  • baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_aliases.cc
  • baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_complex_models.cc
  • baml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_unions.cc
  • baml_language/sdks/cpp/bridge_cpp/include/baml/baml.h
  • baml_language/sdks/cpp/bridge_cpp/include/baml/codec.h
  • baml_language/sdks/cpp/bridge_cpp/include/baml/detail/call.h
  • baml_language/sdks/cpp/bridge_cpp/include/baml/errors.h
  • baml_language/sdks/cpp/bridge_cpp/include/baml/union.h
  • baml_language/sdks/cpp/sdkgen_cpp/src/lib.rs

Comment thread baml_language/sdks/cpp/bridge_cpp/include/baml/union.h
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Binary size checks failed

4 violations · ✅ 3 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 25.2 MB 10.7 MB file 24.5 MB +736.8 KB (+3.0%) FAIL
packed-program Linux 🔒 17.0 MB 7.0 MB file 17.0 MB -4.1 KB (-0.0%) OK
baml-cli macOS 🔒 19.5 MB 9.3 MB file 18.9 MB +628.4 KB (+3.3%) FAIL
packed-program macOS 🔒 13.2 MB 6.2 MB file 13.2 MB +0 B (+0.0%) OK
baml-cli Windows 🔒 21.0 MB 9.5 MB file 20.4 MB +621.6 KB (+3.0%) FAIL
packed-program Windows 🔒 14.1 MB 6.2 MB file 14.1 MB -512 B (-0.0%) OK
bridge_wasm WASM 16.2 MB 🔒 4.4 MB gzip 4.3 MB +130.5 KB (+3.1%) FAIL

🔒 = 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.

Details & how to fix

Violations:

  • baml-cli (Linux) file_bytes: 25.2 MB exceeds limit of 25.2 MB (exceeded by +1.5 KB, policy: max_file_bytes)
  • baml-cli (Linux) file_delta_pct: +3.0% exceeds limit of 3.0% (exceeded by +0.0pp, policy: max_delta_pct)
  • baml-cli (macOS) file_bytes: 19.5 MB exceeds limit of 19.5 MB (exceeded by +61.4 KB, policy: max_file_bytes)
  • baml-cli (macOS) file_delta_pct: +3.3% exceeds limit of 3.0% (exceeded by +0.3pp, policy: max_delta_pct)
  • baml-cli (Windows) file_bytes: 21.0 MB exceeds limit of 21.0 MB (exceeded by +8.8 KB, policy: max_file_bytes)
  • baml-cli (Windows) file_delta_pct: +3.0% exceeds limit of 3.0% (exceeded by +0.0pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 4.4 MB exceeds limit of 4.4 MB (exceeded by +2.3 KB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +3.1% exceeds limit of 3.0% (exceeded by +0.1pp, policy: max_delta_pct)

Add/update baselines:

.ci/size-gate/aarch64-apple-darwin.toml:

[artifacts.baml-cli]
file_bytes = 19528560
stripped_bytes = 19528608
gzip_bytes = 9324057

.ci/size-gate/wasm32-unknown-unknown.toml:

[artifacts.bridge_wasm]
file_bytes = 16160508
gzip_bytes = 4402886

.ci/size-gate/x86_64-pc-windows-msvc.toml:

[artifacts.baml-cli]
file_bytes = 21048320
stripped_bytes = 21048320
gzip_bytes = 9534145

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.baml-cli]
file_bytes = 25244432
stripped_bytes = 25244424
gzip_bytes = 10720500

Generated by cargo size-gate · workflow run

A completeness-critic pass over the whole python suite caught union
coverage living OUTSIDE the dedicated union files that the union-disable
had removed and the re-land did not restore:

- test_lists.cc: round_trip_union_list ((int|string)[]) and
  round_trip_list_container (union_list field)
- test_optional.cc: round_trip_optional_union ((int|string)?) and
  round_trip_optional_container (optional_union field)
- test_forward_refs.cc: round_trip_rec_list (forward_refs' union-bodied
  recursive alias) and round_trip_rec_list_with_other (the only
  union-bodied recursive alias with a class alternative)
- test_raises.cc (new): the free-function subset of test_raises.py --
  union throws list every member unqualified, single/inferred contracts,
  summary-precedes-Raises, and no Raises line on non-throwing functions
- unions_static.cc (new): compile-time contract pins -- canonicalization
  under optional/vector nesting, Union-is-a-variant, and negative
  is_invocable probes that a match missing an arm cannot compile while a
  const auto& catch-all restores invocability

type_shapes 83 cases, function_calls 16; suite 10/10.
baml::Union is regular Google style (type aliases are PascalCase, and
lowercase union is a keyword regardless); match is the deliberate
std-mimicking deviation and belongs in carve-out 2.
Full lowercase stdlib mirroring is impossible (union is a C++ keyword,
so baml::union cannot exist); with Union necessarily PascalCase, Match
follows regular Google function naming instead of carrying a carve-out.
…ing)

The union surface now mirrors std vocabulary end to end:
baml::variant<Ts...> (order-canonical std::variant alias) read with
baml::match. It would be baml::union, but union is a C++ keyword, so
variant keeps the honest name for what it is. union.h -> variant.h;
STYLE.md carve-out 2 covers both spellings.
baml::Union (type alias), baml::Match (function), baml::Unset/kUnset
(sentinel tag + constant, replacing unset_t/unset). Lowercase stdlib
mirroring was ruled out because baml::union cannot exist (keyword);
with Union necessarily PascalCase, the rest follows the guide instead
of carrying carve-outs. variant.h returns to union.h.
Arg::IsSet/IsUnset/Value, Box/OptionalBox::HasValue,
BamlError::Is<T>/Get<T>. What stays snake_case is what the Google guide
itself prescribes: stored-state accessors (message, class_name, ...),
set_<param> mutators on generated opts structs, type traits, and
extern-C symbols. std::optional call sites are untouched (std API).
Structure is Google-cased, vocabulary is std-cased -- exactly Abseil's
practice (absl::optional::has_value, absl::visit, absl::nullopt beside
absl::StatusOr). Types stay PascalCase (Union, Box, Arg, Unset), but
optional/variant-shaped API keeps std spelling so it reads uniformly
next to std types and works in duck-typed generic code:

- baml::Match -> baml::match (mirrors std::visit)
- baml::kUnset -> baml::unset (mirrors std::nullopt)
- Arg::IsSet/IsUnset/Value -> is_set/is_unset/value
- Box/OptionalBox::HasValue -> has_value (uniform with std::optional
  at adjacent call sites)
- BamlError::Is<T>/Get<T> -> is<T>/get<T> (mirror
  std::holds_alternative/std::get)

The rule is written down in STYLE.md; naming is now frozen.
BAML unions are sets, so Union<A, A> must be the same type as Union<A>;
previously it produced std::variant<A, A>, a distinct type with ambiguous
type-based access. The canonical index computation now drops adjacent
duplicate names after the sort.
@codeshaunted
codeshaunted enabled auto-merge July 17, 2026 20:24
@codeshaunted
codeshaunted added this pull request to the merge queue Jul 17, 2026
Merged via the queue into canary with commit b48935e Jul 17, 2026
61 of 62 checks passed
@codeshaunted
codeshaunted deleted the avery/cpp-unions branch July 17, 2026 20:33
pull Bot pushed a commit to justinlietz93/baml that referenced this pull request Jul 18, 2026
…cellation, co_await (BoundaryML#4078)

Bridge-week step 10 for C++ (async half; error/panic behavior landed in
BoundaryML#4071).

## What

Every generated function gains an **Async sibling** returning
`baml::Future<T, ThrownU>`:

```cpp
auto fut = b::ExtractResumeAsync(text);   // returns immediately
fut.Cancel();                             // optional engine-side cancellation
Resume r = fut.get();                     // blocks + decodes; typed throws as BamlThrown<Union<...>>
```

- `get()`/`wait()`/`wait_for()`/`wait_until()`/`valid()` mirror
`std::future` (STYLE.md carve-out 2); `Cancel()` and the sibling's
`Async` suffix are our vocabulary (Pascal). The suffix follows the
opts-struct convention: verbatim BAML spelling + suffix (`probe` ->
`probeAsync`), allocated through the naming pool.
- **Cancellation**: `Cancel()` calls the v1 ABI's
`cancel_function_call`; the envelope still arrives as a
`baml.panics.Cancelled` panic and `get()` throws `BamlCancelled`.
Destruction detaches (never blocks, never cancels) - Python task-model
parity. No Rust changes.
- **co_await (C++20 only)**: the registry's per-call cell is now a
custom `CallState` (mutex + condvar + continuation slot) instead of
`std::promise`, so the dispatcher thread can resume a suspended
coroutine when the envelope lands. The awaiter is feature-gated
(`__cpp_impl_coroutine` + `__cpp_lib_coroutine`); the header stays
C++17-clean.
- **One call path**: `CallSync` is now literally `StartCall().get()`.

## Tests

- `test_cancellation.cc`: port of python's `test_cancellation.py`
(portable core: null-return baseline + engine-side cancel;
asyncio/BamlCallContext idioms documented as deviations) + C++-specific
future semantics (consume-once, `wait_for` timeout, detach-on-destroy).
- Async cases added to `test_optional_args.cc` (python parity),
`test_raises.cc` (async sibling repeats the doc block), `test_errors.cc`
(typed throw through `get()`).
- `futures_static.cc`: move-only + ThrownU order-canonicality static
asserts.
- `tests/cxx20/test_coawait.cc`: 6 live co_await cases (pending resume,
value, fast path, typed throw into the coroutine, cancellation,
escape-to-join) driven by a minimal completion-latch Task. The harness
builds `tests/cxx20/*.cc` as a second executable only when the toolchain
has C++20, so the main binary keeps proving the SDK compiles as plain
C++17.

All 12 `sdk_test_cpp` fixture tests pass locally (26 C++17 + 6 C++20
cases in function_calls).

Note: the size gate is expected to fail until the stale baselines are
refreshed - canary has drifted ~600 KB since BoundaryML#4057 and every open PR is
at the 3% ceiling (see BoundaryML#4071's report).

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

* **New Features**
  * Added dual async C++ SDK bindings (`*Async`) alongside sync calls.
* Introduced `baml::Future` for in-flight calls, including cancellation
and optional C++20 `co_await` support.
* Added typed singleton literal support via `BAML_LIT(...)` for more
precise literal and union typing.
* **Bug Fixes**
* Improved literal encoding/decoding and union arm selection to preserve
exact typed literal values.
  * Enhanced async sibling error propagation and cancellation behavior.
* **Tests**
* Expanded C++ coverage for futures (including C++20), cancellation,
typed throws, optional args, and literal/type-shape checks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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