0.17.0 rejects unary `!` on `bool | null` (E0001) that 0.15/0.16 type-check but panic on at runtime (`VM internal error: cannot apply unary operation: ! any`)
Summary
Unary ! applied to an optional bool (bool | null, produced by an optional chain) behaves differently on every recent line, and two of the three behaviors look unintended:
| toolchain | baml check |
runtime |
|---|---|---|
| 0.15.0 | accepts | VM internal error: cannot apply unary operation: ! any when the optional is null |
| 0.16.0 | accepts | (same class as 0.15 — the check admits what the VM can't evaluate) |
| 0.17.0 | rejects: error[E0001] mismatched types — expected bool, found bool |
null`` |
So 0.15/0.16 have a checker/VM soundness gap (the expression type-checks but panics at runtime on the null branch), and 0.17.0 fixes it by rejecting at check time — a breaking change we could not find in a changelog/migration note. Meanwhile #4498 ("truthiness in condition positions", merged 2026-08-19, apparently post-0.17.0) explicitly makes ! operands truthiness-coercing (null falsy), which would re-legalize the same expression on a later line.
Reproduce
baml_src/main.baml (no clients, no prompts — parses on all three toolchains):
class Feld {
value string,
abstained bool,
}
class Doc {
betreff Feld?,
}
function present() -> Doc { Doc { betreff: Feld { value: "x", abstained: false } } }
function absent() -> Doc { Doc { betreff: null } }
function old_form(d: Doc) -> bool { !d.betreff?.abstained }$ BAML_VERSION=0.15.0 baml check --from .
Finished checked 1 file(s) # green
$ BAML_VERSION=0.16.0 baml check --from .
Finished checked 1 file(s) # green
$ BAML_VERSION=0.17.0 baml check --from .
main.baml:13:37 error[E0001] mismatched types — expected `bool`, found `bool | null`Runtime on the lines that accept it:
$ BAML_VERSION=0.15.0 baml run --from . -e 'old_form(present())'
false # fine — the optional is populated
$ BAML_VERSION=0.15.0 baml run --from . -e 'old_form(absent())'
VM internal error: cannot apply unary operation: ! anyWhere it bit a real consumer
zenzy-atlas (Rust product, CLI seam, 35 BAML functions) migrating 0.15.0 → 0.17.0 per the classic-syntax removal. Two committed test-file asserts of the shape assert.is_true(!f.betreff?.abstained); had been green for months on 0.15 — they only ever passed because the fixtures happened to populate the field; the null branch would have been a hard VM panic, not a failed assert. 0.17.0's E0001 was the first signal. We rewrote them as !(f.betreff?.abstained ?? true) (evaluated on both branches on 0.17.0: present→true, absent→false — the meaning the old form crashed trying to express).
Asks
- Confirm the 0.17.0 E0001 rejection is the intended 0.17-line behavior (it looks like the correct fix for the 0.15/0.16 soundness gap), and add it to a 0.17 migration note —
!optionalis a pattern the old checker admitted, so migrating codebases hit it as a surprise E0001 with no changelog anchor. - Clarify the intended end state relative to #4498: on a truthiness line,
!(bool | null)becomes legal again (!null == true). If that ships in 0.17.1/0.18, code fixed for 0.17.0 stays valid, but it would be good to know whether the strict-E0001 window is deliberate. - If 0.15/0.16 get any further releases: the checker admitting an expression the VM answers with
VM internal erroris worth a backport of either the rejection or the truthiness coercion.
Environment: Linux x86_64, baml wrapper 0.2.0, toolchains 0.15.0 / 0.16.0 / 0.17.0 via BAML_VERSION selector.
Source: BoundaryML/baml