Pattern Guards Design
Status: implemented
Date: 2026-02-11
Goal
Add match branch guards to Bosatsu:
match value:
case Foo(x, y) if fn(x, y):
...
while preserving Bosatsu’s totality guarantees and keeping codegen predictable.
Non-goals
- Proving arbitrary guard predicates for exhaustiveness.
- Introducing effectful guard semantics.
- Replacing the existing pattern set algebra.
Literature Review
Semantics and syntax
-
OCaml supports branch guards (
when) evaluated after the pattern matches, with top-down branch order. Source: OCaml Reference Manual 5.3 (patterns/matching) -
Haskell supports guards and pattern guards; guards are checked after pattern matching in source order. Source: Haskell 2010 Report, Expressions
-
Scala 3 case clauses allow optional guards (
case Pattern [Guard] =>), with a first-match style operational reading. Source: Scala 3 Language Specification, Pattern Matching -
Rust supports
pattern if guardand explicitly states guards run after the pattern matches. Source: Rust Reference, match expressions
Exhaustiveness and guard complexity
-
Rust documents that match guards are not used for exhaustiveness checking. Source: The Rust Book, Match Guards
-
GHC’s pattern-match checker models guards and can be expensive; docs describe an exponential blow-up driver and a model cap (
-fmax-pmcheck-models). Source: GHC Users Guide, -fmax-pmcheck-models -
OCaml tooling still carries explicit guard-related warning categories (for example
All_clauses_guardedin compiler warnings API), which signals practical caution around guarded coverage. Source: OCaml compiler-libs Warnings
Compilation literature
- Maranget-style pattern matrices remain the baseline for decision-tree compilation.
- GHC’s guard-tree work is specifically about integrating guards into coverage and diagnostics. Source: Lower Your Guards (ICFP 2020)
Proposed Bosatsu Semantics
Syntax
Extend match branch syntax to:
case <pattern> [if <nonbinding-expr>] : <declaration>
Dynamic semantics
For each branch, in order:
- Try to match the pattern against the scrutinee.
- If it fails, continue.
- If it succeeds, evaluate the guard in the pattern-extended scope.
- If guard is
True, evaluate branch body. - If guard is
False, continue to next branch.
No change to evaluation order of the scrutinee or branch ordering.
Typing semantics
- Guard expression is checked in the environment extended by pattern bindings.
- Guard must typecheck as
Bool(Type.BoolType). - Guard introduces no new bindings.
- Branch result typing is unchanged.
Core Design Decisions
1. Branch model becomes explicit
Current code uses (Pattern, Expr) pairs throughout parser, source IR, typed IR, normalization, proto, and codegen lowering. With guards, tuples become brittle.
Introduce explicit branch records in all relevant AST layers:
- Parsed declaration branch:
pattern, optionalguard,body. - Untyped Expr branch:
pattern, optionalguard,body. - TypedExpr branch:
pattern, optional typedguard, typedbody.
This reduces accidental field-order bugs and makes normalization/lowering logic clearer.
2. Conservative totality policy
To preserve Bosatsu totality without theorem proving:
- Only unguarded branches contribute to exhaustiveness coverage.
- Guarded branches are checked for pattern validity and body totality, but do not reduce
missingBranches. - Reachability uses only prior unguarded coverage for shadowing.
Consequence: a guarded catch-all (case _ if cond) does not make a match total; an unguarded fallback is still required.
This is intentionally conservative and aligned with practical behavior in Rust/GHC ecosystems.
3. Pattern set algebra stays unchanged
No changes to TotalityCheck.patternSetOps are required.
Set algebra remains over Pattern only.
Changes are localized to branch selection in totality analysis:
- Project branches to coverage patterns via
branch.guard.isEmpty(or futureguardIsTriviallyTrue). - Run existing
missingBranches,unreachableBranches,difference,intersectionunchanged.
Compiler Pipeline Impact
Parser and source AST
Files:
core/src/main/scala/dev/bosatsu/Declaration.scalacore/src/main/scala/dev/bosatsu/SourceConverter.scala
Changes:
- Parse optional guard between pattern and
:. - Pretty-printer emits
case <pat> if <guard>:when present. - Source conversion converts guard under the same pattern-bound scope as branch body.
- SourceConverter canonicalizes guards:
- If guard resolves to Predef
True, rewrite branch as unguarded. - If guard resolves to Predef
False, keep it guarded (do not drop branch in SourceConverter).
Type inference and typed AST
Files:
core/src/main/scala/dev/bosatsu/Expr.scalacore/src/main/scala/dev/bosatsu/TypedExpr.scalacore/src/main/scala/dev/bosatsu/rankn/Infer.scala
Changes:
checkBranch/inferBranchaccept optional guard.- After
typeCheckPattern, extend env with bindings, then typecheck guard asBool. - Continue to branch body typing exactly as today.
Totality and errors
Files:
core/src/main/scala/dev/bosatsu/TotalityCheck.scalacore/src/main/scala/dev/bosatsu/PackageError.scala
Changes:
- Exhaustiveness input patterns become “unguarded branch patterns”.
- Unreachable logic ignores prior guarded branches as covering evidence.
- Error text adds explicit hint that guarded branches do not establish totality.
- With SourceConverter canonicalization,
case p if True:(whereTrueresolves to Predef Bool constructor) participates as an unguarded branch.
Recursion and lint-style analyses
Files:
core/src/main/scala/dev/bosatsu/DefRecursionCheck.scalacore/src/main/scala/dev/bosatsu/UnusedLetCheck.scala
Changes:
- Guards must be traversed anywhere branch bodies are traversed today.
- Pattern-bound names are in scope for guards.
- In recursive contexts, a recursive call inside a guard is allowed iff that call would be allowed in that branch body.
DefRecursionCheckshould validate guard expressions in the same branch state (InRecurBranch) used for the branch body, so recursive-call legality rules are identical.
TypedExprNormalization (guard-aware)
Files:
core/src/main/scala/dev/bosatsu/TypedExprNormalization.scalacore/src/main/scala/dev/bosatsu/Package.scala(ordering note)
Guard-specific requirements:
- Any substitution/inlining rule that can affect branch expressions must also apply to guard expressions.
- When moving lets/lambdas across match branches, free-variable and shadowing checks must consider both guard and body.
- Pattern-bound names still shadow outer names in guards exactly as they do in branch bodies.
Guard simplification rules:
- Normalize/infer substitutions into the guard first.
- If the normalized guard becomes
True, remove the guard from that branch. - If the normalized guard becomes
False, remove that branch as unreachable. - Run existing match simplifications after guard elimination (for example, simpler trailing wildcard rewrites).
Pattern-independent guard hoisting (optimization):
- If a guard does not reference any names bound by its pattern, it can be hoisted into the scrutinee shape:
match x: case p if g: a; case p2: b=> let g0 = g in match (x, g0): case (p, True): a; case (p2, _): b- This can unlock better matrix compilation by turning a guard check into ordinary pattern columns.
- This rewrite should be heuristic-gated because it changes guard evaluation timing from conditional to eager.
- Recommended gate:
- always allow when
gnormalizes to a cheap/simple expression, - or when the same
gis shared across multiple guarded branches (so hoisting avoids repeated evaluation). - Do not apply this rewrite when guard depends on pattern-bound names.
Why this matters:
- It avoids regressions where a rewrite updates the branch body but leaves the guard stale.
- It enables practical optimization from existing inlining/constant-folding work.
- It gives us a path to compile some guards with pure matrix specialization rather than late guard checks.
Pipeline note: TypedExprNormalization currently runs after type inference (Package.inferBody) and after source-level totality checking (Package.inferBodyUnopt).
So guard elimination in normalization improves generated code quality and downstream optimization, but does not change current source totality diagnostics.
Serialization
Files:
proto/src/main/protobuf/bosatsu/TypedAst.protocore/src/main/scala/dev/bosatsu/ProtoConverter.scala
Changes:
- Extend
Branchwith optionalguardExpr. - Decode absent guard as
None. - Encode guard when present.
Compatibility risk: Old decoders could ignore unknown guard fields, so rollout should include a format/version gate to avoid silently dropping guard semantics.
Matchless Design
Files:
core/src/main/scala/dev/bosatsu/Matchless.scala
Data model
Extend matrix row:
pats- optional
guard rhsbinds
Matrix compilation
Keep constructor/literal specialization unchanged.
Guard logic is added only when a row becomes selected (all remaining pats are wildcards).
At row selection:
- Build
rhsExpr = lets(binds, rhs). - If no guard: return
rhsExpr(existing behavior). - If guarded:
- Build guard in bound scope:
gExpr = lets(binds, guard). - Convert expression guard to
BoolExprvia helper (CheapExpr -> isTrueExpr, otherwiseLetBool(..., isTrueExpr(...))). - On guard true:
rhsExpr. - On guard false: continue with remaining rows in original order.
This preserves left-to-right semantics and matrix sharing.
Ordered compiler path
matchExprOrderedCheap must also incorporate guards, because it is used for non-orthogonal list/string patterns and small matches.
Effective branch test becomes:
- pattern condition
- then guard condition (short-circuited)
mustMatch optimization impact
Current optimization can skip some final pattern checks.
With guards:
- Pattern-shape checks may still be skipped where valid.
- Guard checks are never skipped unless proven trivially true.
Trade-offs
Performance
Pros:
- Matrix shape-specialization logic stays mostly unchanged.
- Set algebra complexity does not increase.
- Guard simplification in
TypedExprNormalizationcan remove runtime guard checks.
Costs:
- Additional guard evaluation and branching in compiled Matchless.
- Potential duplication of bound projection work between guard and rhs.
- More normalization work because guard expressions participate in substitution and simplification.
- Guard-hoisting can evaluate some guards earlier; without heuristics this can be a runtime regression.
Error reporting
Pros:
- Sound totality is preserved without predicate proving.
- Diagnostics remain deterministic and simple.
Costs:
- More conservative non-total errors (“add unguarded fallback”) even for logically exhaustive guarded partitions.
- Reachability may be less precise for guards that are always true/false but not syntactically obvious.
- Special-casing Predef
Truein SourceConverter is slightly non-uniform, but it avoids surprising non-total errors for explicitif True.
Implementation Plan
- Add branch data structures and parser support.
- Thread guards through
Declaration -> Expr -> TypedExpr. - Typecheck guards as
BoolinInfer. - Update totality projection and diagnostics.
- Update Matchless matrix + ordered compilers.
- Update recursion/lint traversals.
- Add guard-aware
TypedExprNormalizationrules (substitution +True/Falseelimination + gated pattern-independent hoisting). - Update proto schema + converter.
- Add targeted tests.
Test Plan
- Parser tests for
case p if g:. - Scope tests: guard can use pattern names; later branches cannot.
- Type tests: guard must be
Bool. - SourceConverter canonicalization tests:
- Guard resolving to Predef
Trueis rewritten to unguarded. - Guard resolving to Predef
Falseremains guarded. - Locally-shadowed
Trueis not canonicalized as PredefTrue. - Recursion-check tests:
- A recursive call legal in the branch body is also legal in that branch guard.
- A recursive call illegal in the branch body is also illegal in that branch guard.
- Totality tests:
- Guard-only coverage is rejected.
- Unguarded fallback restores totality.
- Unreachable with prior unguarded patterns is still detected.
- Matchless/evaluation tests:
- Guard false falls through correctly.
- Guard true selects branch.
- Works in matrix and ordered paths.
- TypedExprNormalization tests:
- Let-substitution rewrites guard and body consistently.
- Guard folding removes
if True. - Guard folding removes
if Falsebranches. - Shadowing-sensitive rewrites preserve semantics when guard references pattern names.
- Pattern-independent guard hoisting rewrites to tuple-scrutinee form when gate conditions hold.
- Pattern-dependent guards are not hoisted.
- Proto round-trip tests for guarded branches.
Open Questions
- Should we add a dedicated error kind when all covering branches are guarded?
- Do we want a later SMT-backed or rewrite-backed “guard simplifier” for better diagnostics?