Skip to content

feat: tell an absent optional class field from an explicit null - #5294

Closed
rossirpaulo wants to merge 6 commits into
canaryfrom
paulo/absent-vs-null
Closed

rossirpaulo wants to merge 6 commits into
canaryfrom
paulo/absent-vs-null

Conversation

@rossirpaulo

Copy link
Copy Markdown
Contributor

Problem

A typed BAML class value cannot tell an optional field that is absent from one that holds null:

class Patch { name: string?, expires_in: int? }

baml.json.to_string(baml.json.from_string<Patch>("{}"))                     // {"name":null,"expires_in":null}
baml.json.to_string(baml.json.from_string<Patch>("{\"expires_in\":null}"))  // {"name":null,"expires_in":null}
baml.json.to_string(Patch { name: "x" })                                     // {"name":"x","expires_in":null}

The two decodes produce the same value, and nothing records which state the program meant. As a result:

  • PATCH semantics are impossible. An HTTP client written in BAML cannot say "absent = leave unchanged, null = clear", nor use fields documented as "set to null to reset".
  • Round trips are lossy. Decoding and re-encoding adds null members the input never had.
  • The stdlib works around it by hand. Seven provider clients (Bedrock, Anthropic, Gemini, Vercel images, and OpenAI chat, responses and images) run the encoded request through a null-pruning walker before sending it. Bedrock's comment explains that the pruner then has to exempt keys where a null is meaningful.
  • Hosts hit it too. In Node bridge: omitting an optional class field in a function argument fails with 'Missing field ... in external Instance' #5108, a Node caller that omits an optional field gets Missing field … in external Instance.

The untyped tree from baml.json.parse already keeps the distinction. This PR is about typed class values.

Design

An optional (T?) class field gets three states: absent (never given a value), null, and a value. There is no new syntax and no change to the host wire format or C ABI.

Engine

  • Storage. An absent field's slot holds the VM's existing "nothing was supplied here" word, Value::OMITTED_ARG, aliased as Value::ABSENT. JavaScript's undefined plays the same double role, marking both an omitted argument and a missing property.
  • Ordinary reads (Instance::load_field, try_load_field, field_values) report an absent slot as null. That covers LoadField, ==, hashing, string.from, GC marking, reflection reads, trace capture and outbound conversion, so program code never sees the sentinel.
  • Presence-aware reads (field_slots, is_field_absent) see the raw slot. Only the JSON encoder, deep_copy, the GC pointer rewrite, the heap debugger and reflection use them.
  • Stores. Any store, including null, makes the field present.
  • Visibility. Instance::fields is now pub(crate), so every reader outside bex_vm_types had to pick one of the two views. The compiler listed about 30 sites, and I decided each one individually.

Cost: one compare-and-select per field read. There is no size change to Instance/Object, no new ValueKind, and no limit on field count. A loop doing 60M field reads ran 1.296 s (median) on canary and 1.298 s on this branch: 6 alternating runs each, fasttest profile, within noise.

Language

state how to get it
absent leave the field out of the constructor (Patch { name: "x" }), or call reflect.class.unset_field(p, "expires_in")
null Patch { expires_in: null }, or p.expires_in = null
test reflect.class.is_field_set(p, "expires_in") is false only when absent; p.expires_in == null separates null from a value
  • Constructors. The checker already allows leaving out only fields whose type admits null. The one compiler change is in MIR: an unspecified constructor slot is now const <omitted> rather than const null.
  • Presence-blind operations. Reads, ==, hashing and display ignore presence: an absent field reads as null and compares equal to an explicit null. This keeps code such as assert.equal(decoded, Patch { name: "x", expires_in: null }) working.
  • Spreads. A spread (Patch { ...base }) copies each field as read, so an absent field of base arrives as an explicit null. A test pins this behavior; see Follow-ups.

JSON

Decoding (from_string<T>, to<T>, from_json<T>, deserialize<T>, T.from_json), at every depth:

  • a missing member of an optional field decodes as absent;
  • a null member decodes as null;
  • a missing required member still throws.

Encoding. to_json, to_string and from<T> gain omit_absent: bool = false.

  • By default an absent field is written as null, so output is byte-for-byte what it was before.
  • With omit_absent = true, absent fields are left out at every depth of the structural walk.
  • An explicit null is always written.
  • A baml.ToJson override still owns its whole subtree, so the option does not reach inside an override.
let p = baml.json.from_string<Patch>("{\"expires_in\":null}");
reflect.class.is_field_set(p, "name");                         // false
reflect.class.is_field_set(p, "expires_in");                   // true
baml.json.to_string(p, omit_absent = true);                    // {"expires_in":null}
baml.json.to_string(p);                                        // {"name":null,"expires_in":null}  (unchanged)
baml.json.to_string(Patch { name: "x" }, omit_absent = true);  // {"name":"x"}

Wire and host boundary

The wire format needs no change: InboundClassValue.fields is already a keyed list, so a host expresses "absent" by leaving the key out.

  • Inbound. The engine now accepts a missing key for a nullable field as absent; before, it reported Missing field. A missing required field is still an error. This is the same rule map_matches_class_shape already uses to pick a class arm for a plain host object.
  • Outbound. Return values still carry absent fields as null. This is unchanged on purpose:
    • the Go (baml_go Field) and C++ decoders reject a missing field;
    • TypeScript code would see undefined where it sees null today.

Per-SDK representation (proposal; only Python inbound is in this PR)

SDK today proposed three states in this PR
Python (pydantic v2) the bridge sends every model field, so an unset field goes out as null model_fields_set decides: a field the caller never set (still at its None default) is absent; field=None is null. Outbound, later: leave absent keys out of the dict given to model_validate, and model_fields_set / model_dump(exclude_unset=True) reflect presence for free. inbound
TypeScript the class field is note!: string | null; a missing key is not sent (now absent, previously an error); a key holding undefined is sent as null input: a missing key or undefined means absent, null means null (as with JSON.stringify). Outbound behind an opt-in: absent becomes a missing property. Types: note?: string | null in input positions only, because making output properties optional breaks readers. engine side only (a missing key now works)
Go *T; every field is always sent; decoding requires every field keep *T with nil meaning null, and add AbsentFields []string to generated structs. This is Google API clients' NullFields inverted, so today's nil = null default holds. Decoding fills AbsentFields when the engine omits keys. –
Rust Option<T>; every field is always sent; decoding treats a missing field as None an opt-in field type baml_bridge::Field<T> { Absent, Null, Value(T) } (or Option<Option<T>>) for classes or generators that ask for it; plain Option<T> stays the default. Coordinate with #5287, which is changing the Rust generator now. –

Turning on outbound three-state needs a switch per call or per generator, so that hosts that cannot read a missing key keep working. See the open questions.

What this PR implements

  1. VM (bex_vm_types, bex_heap, bex_vm): the absent slot state and the two read views. GC rewrite, deep_copy and the heap debugger keep presence; ==, hashing and display ignore it.
  2. Compiler (baml_compiler2_mir): an omitted constructor slot is const <omitted>.
  3. JSON (bex_vm package_baml/json.rs, json.baml): both decoders record absent fields, and the structural encoder takes omit_absent.
  4. Reflection (reflect.class): is_field_set(value, name) -> bool and unset_field(value, name). unset_field throws for a non-nullable field, an undeclared field, or a non-instance.
  5. Engine (bex_engine conversion.rs): a host value missing an optional field arrives absent. Outbound is unchanged.
  6. Python bridge: never-set optional model fields are sent as absent. The class_value oneof is now marked present even when every field is unset; otherwise the value would read as null.

Size: +435 / −107 lines excluding tests and snapshots, of which about 180 are comments and doc comments. Tests add about 490 lines.

Compatibility

Presence tracking is always on, but nothing observable changes unless code asks for it.

Unchanged:

  • field reads, ==, hashing, string.from and prompt rendering;
  • values from LLM calls (SAP still fills a missing optional field with null);
  • values returned to hosts;
  • the default output of baml.json.to_json, to_string, from<T> and serialize.

Changed:

  1. A host value that leaves out an optional field is accepted instead of rejected (Node bridge: omitting an optional class field in a function argument fails with 'Missing field ... in external Instance' #5108).
  2. A Python model sends a never-set optional field as absent instead of null. BAML reads both as null, so only the new presence-aware APIs can tell the difference.
  3. baml.json.to_string(x) call sites now lower as to_string(x, const <omitted>). This is visible in one MIR golden and in the stdlib items snapshot, which gains _to_json.

Why omit_absent is opt-in and not the default. I measured the alternative: with omission as the default, 1 of the 5,464 offline runtime corpus tests changed. llm_openai_decisions pins http.Request.to_json() with "timeout": null, "connect_timeout": null for a request the stdlib builds without those fields.

That is little breakage inside this repo. Outside it, the same switch would silently change the bytes existing programs send and store, with no diagnostic. That affects:

  • snapshot tests;
  • recordings that are replayed and compared byte for byte;
  • consumers that index d["key"];
  • schemas that list nullable members as required.

Flipping the default later is one line (omit_absent: bool = true) and belongs at a language-version boundary. I would recommend doing it then, since omission is what the stdlib's own provider clients already do by hand.

Tests

New tests:

  • crates/baml_tests/baml_src/ns_absent_fields/absent_fields.baml (18 runtime tests). Covers:
    • constructor omission vs. explicit null;
    • default vs. omit_absent encoding, including nested classes, lists and optional classes;
    • from / to_json with the option;
    • from_string and to keeping a missing member apart from null at every depth;
    • round trips of four documents;
    • assignment and unset_field, plus its errors (non-nullable field, unknown field, non-instance);
    • presence-blind ==, display, and the pinned spread behavior.
  • crates/bex_engine/tests/absent_fields.rs (5 host-marshalling tests):
    • omitted vs. explicit null fields on an Instance and on a plain host map;
    • a missing required field is still rejected;
    • outbound still carries null.
  • bex_vm_types: a unit test for the two slot read views.
  • sdks/python/tests/test_absent_fields.py: wire entries for set, unset and assigned fields, plus an end-to-end call through a real runtime (Patch() → {}, Patch(expires_in=None) → {"expires_in":null}).

Commands, all on macOS arm64 with telemetry off and a throwaway BAML_HOME:

command result
baml-cli test --from crates/baml_tests/baml_src (full runtime corpus) 5,464 passed, 15 failed. Canary gives 5,446 passed, 15 failed, with the identical failure set. Those 15 are trace* tests that need telemetry; CI runs this corpus with BAML_TELEMETRY=medium.
cargo nextest run -p bex_vm_types -p bex_heap -p bex_vm -p baml_compiler2_mir -p baml_compiler2_emit 498 / 498
cargo nextest run -p bex_engine -p baml_tests -p bex_project -p baml_exec 1,655 passed. The 2 snapshot tests failed until I accepted the expected updates, and pass now. The other 28 failures are telemetry/trace tests that fail identically on canary with telemetry off.
cargo test -p bex_engine --test absent_fields 5 / 5
sdks/python: uv run pytest tests/ 147 passed, 8 failed. Canary gives 144 passed with the identical 8 failures.
cargo nextest run -p sdk_test_python_pydantic2 16 / 17. function_calls::pytest fails on four tracing tests, identically on canary with telemetry off.
cargo fmt --check, cargo clippy --workspace --all-targets --all-features -- -D warnings clean (the pre-commit hook ran clippy on every commit's tree)
ruff check / ruff format --check on the changed Python files; pyright clean. Pyright's error count is unchanged from canary, with none in the changed code.

Negative controls. Each one breaks a single layer, rebuilds, and reruns the new tests:

change reverted result
constructor fills omitted slots with null 7 of 18 absent_fields tests fail
from_string decodes a missing member as null 3 fail
to<T> decodes a missing member as null 1 fails
encoder ignores omit_absent 8 fail
engine rejects an omitted optional host field 3 of 5 engine tests fail
Python bridge sends unset fields 2 of 3 Python tests fail

Follow-ups

  • Outbound three-state, per SDK. Pick the switch first (open question 5), then do Python, TypeScript, Go and Rust as in the table above. Rough sizes:
    • TypeScript bridge undefined handling: about 30 lines, plus tests and a dist rebuild;
    • TypeScript input types: about 100 generator lines;
    • Go generator and runtime: about 250 lines;
    • Rust generator and bridge: about 200 lines, after fix(sdk-rust): map json to serde_json::Value instead of skipping it #5287;
    • engine outbound flag: about 50 lines, plus plumbing in each bridge.
  • Presence through spreads. Patch { ...base, name: "x" } should keep base's absent fields absent. This needs a spread-specific copy in MIR/emit, because a plain field copy must keep its "a read gives null" semantics. About 80 lines.
  • Stdlib pruners. Where a pruner only drops the client's own unset options, build requests by omission and use omit_absent = true instead. Keep explicit-null pruning only where it is intended.
  • Trace values show an absent field as null; a $absent marker could follow.
  • Typed CSV path. json_to_string_typed writes absent as null.

Open questions

  1. Should omit_absent default to true at the next language-version boundary?
  2. Is presence-blind == right? Pydantic v2's __eq__ ignores model_fields_set; TypeScript deep-equal libraries do not ignore missing keys.
  3. Should absent share the OMITTED_ARG word, or get its own? A dedicated word means a new ValueKind variant and touching every exhaustive match.
  4. API names and placement: reflect.class.is_field_set / unset_field are string-keyed, so a misspelled field name is a runtime error. A statically checked form would need syntax.
  5. How should outbound three-state be switched on: per generator (for example field_presence = true in the generator block), per class, or per call?
  6. ARCHITECTURE.md asks for two reviewers on MIR changes. The change here is one constant in lower_object.

Coordination

🤖 Generated with Claude Code

rossirpaulo and others added 6 commits October 16, 2026 00:12
An instance slot can now hold the VM's "nothing was supplied here" word
(`Value::OMITTED_ARG`, aliased `Value::ABSENT`) to mean an optional field
that was never given a value, as distinct from one holding `null`.

The ordinary readers (`Instance::load_field`, `try_load_field`,
`field_values`) report an absent slot as `null`, so field reads, `==`,
hashing, display, GC marking, trace capture, and values returned to hosts
behave exactly as before. Presence-aware code reads the raw slot through
`field_slots` / `is_field_absent`: the GC pointer rewrite and `deep_copy`
keep it. Any store makes the field present.

`Instance::fields` becomes `pub(crate)` so every reader outside
`bex_vm_types` picks one of the two views explicitly.

Nothing produces an absent slot yet; later commits do.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ctors

A class constructor may leave out a field whose type admits `null`. MIR
used to fill that slot with `const null`; it now fills it with
`const <omitted>`, so the field is absent rather than explicitly `null`.
`Patch { name: null }` still stores an explicit `null`.

Reads are unchanged (an absent field reads as `null`), and so is every
encoder at this point.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ncoding

Decoding (`from_string<T>`, `to<T>`, `from_json<T>`, `deserialize<T>`)
now leaves an optional field absent when its JSON object has no such
member, and stores `null` when the member is an explicit `null`. A missing
required field still throws.

`baml.json.to_json`, `to_string` and `from<T>` take
`omit_absent: bool = false`. By default an absent field is still written
as `null`, so existing output is byte-for-byte unchanged. With
`omit_absent = true` the structural walk leaves absent fields out at every
depth while still writing explicit nulls, so a decoded object encodes back
with the same members:

    let p = baml.json.from_string<Patch>("{\"expires_in\":null}");
    baml.json.to_string(p, omit_absent = true)   // {"expires_in":null}

`to_json` becomes a BAML wrapper over the native `_to_json`, which takes
the option resolved. Call sites of `to_string(x)` now lower with a
trailing `const <omitted>` argument (one MIR golden), and the stdlib item
list gains `_to_json`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
`reflect.class.is_field_set(value, name)` reports whether a field has been
given a value (`null` included), which a read cannot tell since an absent
field reads as `null`. `reflect.class.unset_field(value, name)` makes an
optional field absent again; it throws for a field whose type does not
admit `null`, for an undeclared field, and for a non-instance, the same
error `get_field` throws.

Adds the `ns_absent_fields` runtime tests covering constructors,
assignment, both decoders, encoding with and without `omit_absent`
(nested classes, lists, optionals), round trips, `unset_field`,
presence-blind `==`, display, and spreads (which copy a field as read, so
an absent field arrives as `null`).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A class value from a host that leaves out an optional field used to fail
with "Missing field ... in external Instance". The field now arrives
absent, the same rule `map_matches_class_shape` already applies when it
picks a class arm for a plain host object, and an explicit `null` entry
arrives as `null`. A missing required field is still an error.

Values returned to hosts are unchanged: an absent field still leaves the
engine as `null`, because the Go and C++ decoders require every declared
field.

Refs #5108

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The bridge sent every pydantic model field, so a field the caller never
set went out as `null`. It now skips a field that is not in
`model_fields_set` and still holds its `None` default, so BAML sees it
absent; `Patch(name="x", expires_in=None)` still sends an explicit `null`.
BAML reads both as `null`; only `reflect.class.is_field_set` and
`omit_absent` encoding tell them apart.

With every field unset the class entry list is empty, so the encoder now
marks the `class_value` oneof present explicitly; otherwise the value would
be read as `null`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@vercel

vercel Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
developer-docs Ready Ready Preview Oct 16, 2026 7:22am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.

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

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

@github-actions

Copy link
Copy Markdown

Binary size checks failed

❌ 2 violations · ✅ 1 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 🔒 61.6 MB 26.2 MB file 73.1 MB -11.5 MB (-15.7%) OK
❌ packed-program Linux 🔒 39.8 MB 16.2 MB file 29.2 MB +10.7 MB (+36.5%) FAIL
❌ bridge_wasm WASM 26.0 MB 🔒 7.6 MB gzip 5.7 MB +2.0 MB (+34.9%) 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:

  • packed-program (Linux) file_bytes: 39.8 MB exceeds limit of 30.1 MB (exceeded by +9.7 MB, policy: max_file_bytes)
  • packed-program (Linux) file_delta_pct: +36.5% exceeds limit of 3.0% (exceeded by +33.5pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 7.6 MB exceeds limit of 5.9 MB (exceeded by +1.8 MB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +34.9% exceeds limit of 3.0% (exceeded by +31.9pp, policy: max_delta_pct)

Add/update baselines:

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

[artifacts.bridge_wasm]
file_bytes = 26038191
gzip_bytes = 7638368

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

[artifacts.packed-program]
file_bytes = 39800704
gzip_bytes = 16225885

Generated by cargo size-gate · workflow run

@rossirpaulo

Copy link
Copy Markdown
Contributor Author

Closing. The preferred direction is to keep optional fields two-state (an omitted optional field is null) rather than add an absent state. Classes that need a different wire shape can implement baml.ToJson themselves. The host-side "Missing field … in external Instance" error (#5108) is fixed separately in #5291.

This branch was successfully deployed

1 active deployment
Preview – developer-docs — 77dfd8e9 Deployed Oct 16, 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