Skip to content

feat: implement BEP-020 optional chaining (?.) and null coalescing (??)#3267

Open
antoniosarosi wants to merge 1 commit intocanaryfrom
bep-020-optional-chaining-null-coalescing
Open

feat: implement BEP-020 optional chaining (?.) and null coalescing (??)#3267
antoniosarosi wants to merge 1 commit intocanaryfrom
bep-020-optional-chaining-null-coalescing

Conversation

@antoniosarosi
Copy link
Contributor

@antoniosarosi antoniosarosi commented Mar 24, 2026

Summary

  • Implements BEP-020 optional chaining (?.) and null coalescing (??) across the compiler 2 pipeline
  • ??= excluded per scope
  • ?. lexed as a single token; ?? handled at parser level (two ? tokens) to avoid ambiguity with int?? (double optional)
  • Three forms of optional chaining: obj?.field, obj?.[expr], obj?.(args)
  • Type inference: a?.b unwraps Optional, resolves member, re-wraps; a ?? b unwraps LHS Optional and joins with RHS
  • VIR/MIR/bytecode emission deferred (pending compiler 2 port)

Test plan

  • Lexer tests for ?. and ?? tokenization
  • Parser tests (existing + new project snapshots)
  • TIR type inference tests for ?? unwrapping, ?. field/index/call
  • Project-level snapshot tests (projects/optional_chaining/) across all compiler phases
  • Full workspace test suite passes (800+ tests)

Summary by CodeRabbit

Release Notes

  • New Features
    • Optional chaining operator (?.) enables safe property access on nullable objects, supporting field access (obj?.field), indexing (obj?.[expr]), and function calls (func?.(args))
    • Null coalescing operator (??) provides fallback values when expressions evaluate to null, allowing expr ?? default syntax

Implements the optional chaining and null coalescing operators from BEP-020
across the compiler 2 pipeline (lexer through type inference). The ??=
assignment operator is excluded per scope.

Lexer: Added ?. as a single token. ?? is handled at parser level (two ?
tokens combined) to avoid ambiguity with int?? (double optional).

Parser: ?. dispatches to optional field access, index, or call based on
the following token. ?? uses Pratt parsing with binding power between
assignment and logical OR.

AST: Added OptionalFieldAccess, OptionalIndex, OptionalCall expr variants
and BinaryOp::NullCoalesce.

Type inference: a?.b unwraps Optional, resolves member, re-wraps in
Optional. a ?? b unwraps Optional from LHS and joins with RHS type.
@vercel
Copy link

vercel bot commented Mar 24, 2026

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

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Mar 24, 2026 1:46am
promptfiddle Ready Ready Preview, Comment Mar 24, 2026 1:46am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

This PR adds optional chaining (?.) and null coalescing (??) operators to BAML. Changes span the lexer (new token QuestionDot), parser (Pratt infix/postfix parsing for optional chaining forms and null coalescing), AST (three new expression variants and NullCoalesce binary operator), type inference (new typing rules for all three optional expressions and null coalescing), and visualization/rendering (support for displaying these new constructs).

Changes

Cohort / File(s) Summary
Lexer and Syntax Kinds
baml_language/crates/baml_compiler_lexer/src/tokens.rs, baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs
Added TokenKind::QuestionDot for ?. operator with lexer/display support; introduced SyntaxKind variants for QUESTION_DOT, QUESTION_QUESTION, and three optional expression node kinds (OPTIONAL_CALL_EXPR, OPTIONAL_INDEX_EXPR, OPTIONAL_FIELD_ACCESS_EXPR); updated is_operator method.
AST Core Definitions
baml_language/crates/baml_compiler2_ast/src/ast.rs
Extended Expr enum with three optional chaining variants (OptionalFieldAccess, OptionalIndex, OptionalCall); added NullCoalesce variant to BinaryOp enum; implemented Display trait for BinaryOp.
Parser
baml_language/crates/baml_compiler_parser/src/parser.rs
Implemented Pratt infix/postfix parsing for optional chaining operators (handling obj?.field, obj?.[expr], callee?.(args)); added null-coalescing support via consecutive ?? tokens; introduced peek_after_question_dot() helper; adjusted binding power ranges for logical and comparison operators.
Expression Lowering
baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
Added lowering methods for three optional expression kinds (lower_optional_field_access_expr, lower_optional_index_expr, lower_optional_call_expr); updated binary operator lowering to recognize QUESTION_QUESTION and lone QUESTION as NullCoalesce; extended is_expr_node_kind to classify new optional expression nodes.
Type Inference
baml_language/crates/baml_compiler2_tir/src/builder.rs
Implemented type-inference rules for optional chaining expressions (removing null from base, returning optional result type); added NullCoalesce binary operator typing (joining non-null LHS with RHS type); extended throw/fact collection for new expression forms.
Visualization and Rendering
baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs, baml_language/crates/tools_onionskin/src/compiler.rs
Added compact formatting for optional chaining/index/call expressions and null-coalescing operator in CFG labels and TIR2 display.
Test Project and Infrastructure
baml_language/crates/baml_tests/projects/optional_chaining/optional_chaining.baml, baml_language/crates/baml_tests/src/compiler2_tir/inference.rs, baml_language/crates/baml_tests/src/compiler2_tir/mod.rs, baml_language/crates/baml_tests/src/compiler2_tir/phase3a.rs, baml_language/crates/baml_tests/src/compiler2_tir/phase7.rs
Added test project with domain classes and functions exercising optional chaining and null coalescing; updated snapshot assertions across multiple test modules to reflect new rendering formats for binary operators and optional expressions.

Sequence Diagram

sequenceDiagram
    participant Source as Source Code
    participant Lexer as Lexer
    participant Parser as Parser
    participant AST as AST/Lowering
    participant TIR as Type Inference
    participant Viz as Visualization

    Source->>Lexer: `user?.name ?? "default"`
    Lexer->>Lexer: Tokenize: IDENTIFIER, QuestionDot, WORD, QuestionQuestion, STRING
    Lexer->>Parser: Token Stream
    Parser->>Parser: Parse QuestionDot as infix postfix operator
    Parser->>Parser: Parse QuestionQuestion as binary operator
    Parser->>AST: OPTIONAL_FIELD_ACCESS_EXPR, BINARY_EXPR(NullCoalesce)
    AST->>TIR: Lower to Expr::OptionalFieldAccess & Expr::Binary(NullCoalesce)
    TIR->>TIR: Infer OptionalFieldAccess: remove null from base, wrap result in Optional
    TIR->>TIR: Infer NullCoalesce: join non-null LHS type with RHS type
    TIR->>Viz: Type-inferred expressions
    Viz->>Viz: Render `user?.name ?? "default"` for CFG/diagnostic labels
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #3217: Related integration PR that modifies the same compiler2 AST, parser, lexer, lowering, and type-inference code paths for optional-chaining and null-coalescing features.

Suggested reviewers

  • hellovai
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature implemented: BEP-020 optional chaining (?.) and null coalescing (??). It is specific, concise, and reflects the primary changes across the compiler pipeline.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 bep-020-optional-chaining-null-coalescing

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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/tools_onionskin/src/compiler.rs (1)

1938-1995: ⚠️ Potential issue | 🟡 Minor

Use symbol rendering in the compact TIR2 tree path.

Line 1965 still formats binaries as {op:?}, so expressions like u?.name ?? fallback collapse to ... NullCoalesce ... in the tree output even though the richer renderers print ??. That makes the new operator look unsupported in one of the main onionskin views.

🛠️ Minimal fix
-                Expr::Binary { op, .. } => format!("... {op:?} ..."),
+                Expr::Binary { op, .. } => format!("... {} ...", binop_sym(op)),

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 16546e43-ca05-4880-884a-9f3a106fa33c

📥 Commits

Reviewing files that changed from the base of the PR and between dddd673 and 1178dc5.

⛔ Files ignored due to path filters (11)
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__01_lexer__optional_chaining.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__02_parser__optional_chaining.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/optional_chaining/baml_tests__optional_chaining__10_formatter__optional_chaining.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase3a__chained_optional_field_access.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase3a__optional_chaining_with_null_coalesce.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase3a__optional_field_access.snap is excluded by !**/*.snap
📒 Files selected for processing (13)
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs
  • baml_language/crates/baml_compiler_lexer/src/tokens.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs
  • baml_language/crates/baml_tests/projects/optional_chaining/optional_chaining.baml
  • baml_language/crates/baml_tests/src/compiler2_tir/inference.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/phase3a.rs
  • baml_language/crates/baml_tests/src/compiler2_tir/phase7.rs
  • baml_language/crates/tools_onionskin/src/compiler.rs

Comment on lines +1452 to +1475
fn lower_optional_field_access_expr(&mut self, node: &SyntaxNode) -> ExprId {
// OPTIONAL_FIELD_ACCESS_EXPR: <base_expr> QUESTION_DOT WORD
let mut base = None;
let mut field = None;

for elem in node.children_with_tokens() {
match elem {
rowan::NodeOrToken::Node(child) => {
if base.is_none() {
base = Some(self.lower_expr(&child));
}
}
rowan::NodeOrToken::Token(token) => {
if token.kind() == SyntaxKind::WORD && base.is_some() {
field = Some(Name::new(token.text()));
}
}
}
}

let base = base.unwrap_or_else(|| self.alloc_expr(Expr::Missing, node.text_range()));
let field = field.unwrap_or_else(|| Name::new("_"));

self.alloc_expr(Expr::OptionalFieldAccess { base, field }, node.text_range())
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

foo?.bar currently loses its base during lowering.

For simple bases, the parser hands this node to lowering as WORD QUESTION_DOT WORD with no child expression node. This implementation only fills base from child nodes, so foo?.bar ends up as Expr::OptionalFieldAccess { base: Missing, field: "_" }.

🛠️ Proposed fix
 fn lower_optional_field_access_expr(&mut self, node: &SyntaxNode) -> ExprId {
     // OPTIONAL_FIELD_ACCESS_EXPR: <base_expr> QUESTION_DOT WORD
     let mut base = None;
     let mut field = None;
+    let mut seen_question_dot = false;
 
     for elem in node.children_with_tokens() {
         match elem {
             rowan::NodeOrToken::Node(child) => {
-                if base.is_none() {
+                if !seen_question_dot && base.is_none() {
                     base = Some(self.lower_expr(&child));
                 }
             }
             rowan::NodeOrToken::Token(token) => {
-                if token.kind() == SyntaxKind::WORD && base.is_some() {
+                if token.kind() == SyntaxKind::QUESTION_DOT {
+                    seen_question_dot = true;
+                } else if !seen_question_dot && base.is_none() {
+                    base = self.try_lower_bare_token(&token);
+                } else if seen_question_dot
+                    && token.kind() == SyntaxKind::WORD
+                    && field.is_none()
+                {
                     field = Some(Name::new(token.text()));
                 }
             }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn lower_optional_field_access_expr(&mut self, node: &SyntaxNode) -> ExprId {
// OPTIONAL_FIELD_ACCESS_EXPR: <base_expr> QUESTION_DOT WORD
let mut base = None;
let mut field = None;
for elem in node.children_with_tokens() {
match elem {
rowan::NodeOrToken::Node(child) => {
if base.is_none() {
base = Some(self.lower_expr(&child));
}
}
rowan::NodeOrToken::Token(token) => {
if token.kind() == SyntaxKind::WORD && base.is_some() {
field = Some(Name::new(token.text()));
}
}
}
}
let base = base.unwrap_or_else(|| self.alloc_expr(Expr::Missing, node.text_range()));
let field = field.unwrap_or_else(|| Name::new("_"));
self.alloc_expr(Expr::OptionalFieldAccess { base, field }, node.text_range())
fn lower_optional_field_access_expr(&mut self, node: &SyntaxNode) -> ExprId {
// OPTIONAL_FIELD_ACCESS_EXPR: <base_expr> QUESTION_DOT WORD
let mut base = None;
let mut field = None;
let mut seen_question_dot = false;
for elem in node.children_with_tokens() {
match elem {
rowan::NodeOrToken::Node(child) => {
if !seen_question_dot && base.is_none() {
base = Some(self.lower_expr(&child));
}
}
rowan::NodeOrToken::Token(token) => {
if token.kind() == SyntaxKind::QUESTION_DOT {
seen_question_dot = true;
} else if !seen_question_dot && base.is_none() {
base = self.try_lower_bare_token(&token);
} else if seen_question_dot
&& token.kind() == SyntaxKind::WORD
&& field.is_none()
{
field = Some(Name::new(token.text()));
}
}
}
}
let base = base.unwrap_or_else(|| self.alloc_expr(Expr::Missing, node.text_range()));
let field = field.unwrap_or_else(|| Name::new("_"));
self.alloc_expr(Expr::OptionalFieldAccess { base, field }, node.text_range())

Comment on lines +1645 to +1650
Expr::OptionalCall { callee, args } => {
self.collect_effective_throws_from_expr(*callee, body, out);
for arg in args {
self.collect_effective_throws_from_expr(*arg, body, out);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing transitive throw propagation for OptionalCall.

The collect_effective_throws_from_expr handler for Expr::OptionalCall only collects throws from the callee and argument expressions, but unlike Expr::Call (lines 1553-1567), it doesn't look up the call target's transitive throw set via function_throw_sets. If the callee resolves to a throwing function, those throws won't be propagated.

🐛 Proposed fix
             Expr::OptionalCall { callee, args } => {
                 self.collect_effective_throws_from_expr(*callee, body, out);
                 for arg in args {
                     self.collect_effective_throws_from_expr(*arg, body, out);
                 }
+                // Propagate transitive throws from the callee function (if resolvable).
+                if let Some(target) = self.call_target_name(*callee, body) {
+                    let throws = crate::throw_inference::function_throw_sets(
+                        self.context.db(),
+                        self.package_id,
+                    );
+                    if let Some(transitive) = throws.transitive_for(&target) {
+                        out.extend(transitive.iter().cloned());
+                    }
+                }
             }

Comment on lines +1790 to +1795
Expr::OptionalCall { callee, args } => {
self.collect_throw_facts_from_expr(*callee, body, out);
for arg in args {
self.collect_throw_facts_from_expr(*arg, body, out);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing transitive throw lookup in collect_throw_facts_from_expr for OptionalCall.

Same issue as collect_effective_throws_from_expr: the Expr::Call handler (lines 1711-1725) includes transitive throw lookup via call_target_name and function_throw_sets, but OptionalCall doesn't.

🐛 Proposed fix
             Expr::OptionalCall { callee, args } => {
                 self.collect_throw_facts_from_expr(*callee, body, out);
                 for arg in args {
                     self.collect_throw_facts_from_expr(*arg, body, out);
                 }
+                if let Some(target) = self.call_target_name(*callee, body) {
+                    let throws = crate::throw_inference::function_throw_sets(
+                        self.context.db(),
+                        self.package_id,
+                    );
+                    if let Some(transitive) = throws.transitive_for(&target) {
+                        out.extend(transitive.iter().cloned());
+                    }
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Expr::OptionalCall { callee, args } => {
self.collect_throw_facts_from_expr(*callee, body, out);
for arg in args {
self.collect_throw_facts_from_expr(*arg, body, out);
}
}
Expr::OptionalCall { callee, args } => {
self.collect_throw_facts_from_expr(*callee, body, out);
for arg in args {
self.collect_throw_facts_from_expr(*arg, body, out);
}
if let Some(target) = self.call_target_name(*callee, body) {
let throws = crate::throw_inference::function_throw_sets(
self.context.db(),
self.package_id,
);
if let Some(transitive) = throws.transitive_for(&target) {
out.extend(transitive.iter().cloned());
}
}
}

Comment on lines +15 to +42
// Functions using optional chaining and null coalescing.
// These test that the parser correctly handles the new syntax and
// that TIR type inference produces correct types.

// Optional field access: obj?.field
function OptionalFieldAccess(user: User?) -> string? {
user?.name
}

// Null coalescing: expr ?? default
function NullCoalesceBasic(x: int?, y: int) -> int {
x ?? y
}

// Optional chaining with null coalescing
function OptionalChainWithDefault(user: User?, fallback: string) -> string {
user?.name ?? fallback
}

// Chained optional access: obj?.field1?.field2
function ChainedOptionalAccess(user: User?) -> string? {
user?.address?.street
}

// Nested optional chaining on nullable field
function NestedOptionalChain(user: User?) -> string? {
user?.address?.zip
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Extend this project fixture to hit ?.[ and ?.( too.

Lines 19-42 currently smoke-test ?.field and ??, but the end-to-end project snapshot never exercises OPTIONAL_INDEX_EXPR or OPTIONAL_CALL_EXPR. Adding one array-index case and one optional-call case would keep this fixture aligned with the full BEP-020 surface.

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 24, 2026

Merging this PR will not alter performance

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

✅ 15 untouched benchmarks
⏩ 91 skipped benchmarks1


Comparing bep-020-optional-chaining-null-coalescing (1178dc5) with canary (dddd673)

Open in CodSpeed

Footnotes

  1. 91 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions
Copy link

No description provided.


// Optional field access: obj?.field
function OptionalFieldAccess(user: User?) -> string? {
user?.name
Copy link
Contributor

Choose a reason for hiding this comment

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

i think this PR is missing optional chaining assignment:

user?.name?.first = "hi"

It's discussed in the BEP as well (and whether we short circuit the RHS if the LHS is null).

Copy link
Contributor

Choose a reason for hiding this comment

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

add a few mroe test cases from the BEP like

user?.(myFunc())?.hi

and (user?.hello).myProp

which tests parentheses as precedence

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.

2 participants