Back to flin
flin

Labeled Loops and Or-Patterns

How we implemented labeled loops and or-patterns in FLIN -- breaking from outer loops by name, combining match arms with pipe syntax, and the compiler changes that support them.

Thales & Claude | March 25, 2026 11 min flin
flinlabeled-loopsor-patternscontrol-flow

Sessions 154 and 155 tackled two features that address a common complaint about programming languages: the inability to express certain control flow patterns cleanly. Labeled loops solve the nested loop escape problem. Or-patterns solve the duplicated match arm problem. Both are small features with outsized ergonomic impact.

The Nested Loop Problem

Consider searching a 2D grid for a value:

// Without labeled loops
found = false
for row in grid {
    for cell in row {
        if cell == target {
            found = true
            break    // breaks inner loop only
        }
    }
    if found { break } // need this extra check
}

The break inside the inner loop only exits the inner loop. To exit the outer loop, you need a flag variable and an additional check. This is verbose, error-prone (forget the outer check and you have a bug), and obscures the intent.

With labeled loops:

'search: for row in grid {
    for cell in row {
        if cell == target {
            break 'search    // exits the outer loop directly
        }
    }
}

One statement. Clear intent. No flag variable. No extra check.

Label Syntax

FLIN uses the tick-prefix syntax for labels, following Rust's convention:

'outer: for i in 0..10 {
    'inner: for j in 0..10 {
        if condition(i, j) {
            break 'outer     // exit both loops
        }
        if other(j) {
            continue 'outer  // skip to next i iteration
        }
    }
}

The tick prefix ('label) was chosen over alternatives for several reasons:

  • Colons alone (outer:) could be confused with type annotations
  • At-signs (@outer) are already used for temporal operations in FLIN
  • Tick prefix is familiar from Rust and visually distinct from other syntax

Labels are scoped to the loop they annotate. Using a label that does not refer to an enclosing loop is a compile-time error:

'outer: for i in items {
    // ...
}
// ERROR: 'outer' is not an enclosing loop
break 'outer

AST Representation

Labels are added to loop statements and break/continue statements:

Stmt::For {
    label: Option<String>,   // NEW
    variable: String,
    iterator: Expr,
    body: Block,
    span: Span,
}

Stmt::While { label: Option, // NEW condition: Expr, body: Block, span: Span, }

Stmt::Break { value: Option, label: Option, // NEW span: Span, }

Stmt::Continue { label: Option, // NEW span: Span, } ```

The label field is Option -- most loops and breaks do not use labels, so the common case carries no overhead.

Parser Changes

The parser detects labels by checking for the tick-identifier pattern before a loop keyword:

fn parse_statement(&mut self) -> Result<Stmt, ParseError> {
    // Check for label: 'name: for/while
    if self.check(&Token::Tick) {
        let label = self.parse_label()?;
        self.expect(&Token::Colon)?;

if self.check_keyword("for") { return self.parse_for_with_label(Some(label)); } if self.check_keyword("while") { return self.parse_while_with_label(Some(label)); } return Err(ParseError::new( "label must be followed by 'for' or 'while'", self.current_span(), )); } // ... rest of statement parsing }

fn parse_label(&mut self) -> Result { self.expect(&Token::Tick)?; self.expect_identifier() } ```

Break and continue parse an optional label:

fn parse_break(&mut self) -> Result<Stmt, ParseError> {
    self.expect_keyword("break")?;

let label = if self.check(&Token::Tick) { Some(self.parse_label()?) } else { None };

let value = if !self.is_at_statement_end() && label.is_none() { Some(self.parse_expression()?) } else { None };

Ok(Stmt::Break { value, label, span: self.current_span() }) } ```

Note the precedence: break 'label is checked before break value. If a tick follows break, it is a label, not a value expression. If neither is present, it is a plain break.

Type Checking Labels

The type checker maintains a stack of active loop labels:

struct LoopContext {
    label: Option<String>,
    break_types: Vec<FlinType>,
}

fn check_for_loop(&mut self, label: &Option, variable: &str, iter: &Expr, body: &Block) { self.loop_stack.push(LoopContext { label: label.clone(), break_types: vec![], });

// ... check body ...

self.loop_stack.pop(); }

fn check_break(&mut self, label: &Option, value: &Option, span: Span) { let target = match label { Some(name) => { self.loop_stack.iter().rev() .find(|ctx| ctx.label.as_ref() == Some(name)) } None => self.loop_stack.last(), };

if target.is_none() { if let Some(name) = label { self.report_error(&format!("no enclosing loop with label '{}'", name)); } else { self.report_error("break outside of loop"); } } } ```

When a labeled break is encountered, the type checker searches the loop stack for a matching label. If none is found, it reports an error. This prevents breaking to a loop that does not exist or is not an ancestor of the current position.

Code Generation for Labeled Breaks

At the bytecode level, labeled breaks compile to jumps that target the specific loop's exit point:

fn emit_for_loop(&mut self, label: &Option<String>, var: &str, iter: &Expr, body: &Block) {
    let loop_info = LoopInfo {
        label: label.clone(),
        start_offset: self.current_offset(),
        break_jumps: vec![],
        continue_jumps: vec![],
    };
    self.loop_info_stack.push(loop_info);

// ... emit loop body ...

// Patch all break jumps (including labeled ones) to here let exit_offset = self.current_offset(); let loop_info = self.loop_info_stack.pop().unwrap(); for jump in loop_info.break_jumps { self.patch_jump_to(jump, exit_offset); } }

fn emit_break(&mut self, label: &Option, value: &Option) { if let Some(value) = value { self.emit_expr(value); self.emit_store(self.get_loop_result_slot(label)); }

let jump = self.emit_jump_forward();

// Register the jump with the target loop let target = match label { Some(name) => self.loop_info_stack.iter_mut().rev() .find(|info| info.label.as_ref() == Some(name)), None => self.loop_info_stack.last_mut(), };

if let Some(target) = target { target.break_jumps.push(jump); } } ```

When break 'outer is emitted, the jump is registered with the outer loop's break jump list. When the outer loop finishes emitting, it patches all its break jumps to the exit point. This correctly handles the case where a break in an inner loop jumps past the inner loop's exit to the outer loop's exit.

Labeled Continue

The continue statement also supports labels:

'outer: for row in grid {
    for cell in row {
        if cell.is_empty() {
            continue 'outer  // skip to next row
        }
        process(cell)
    }
}

continue 'outer skips the rest of the inner loop's current iteration and the rest of the outer loop's current iteration, jumping to the start of the next outer iteration. Without labels, you would need a flag variable to achieve the same effect.

Or-Patterns

Sessions 154-155 also added or-patterns to match expressions. An or-pattern combines multiple patterns into a single arm:

match status {
    "active" | "enabled" -> show_active()
    "pending" | "queued" | "waiting" -> show_pending()
    "error" | "failed" -> show_error()
    _ -> show_unknown()
}

The | between patterns means "match if any of these patterns match." The arm body executes once, regardless of which alternative matched.

Or-Pattern AST

Or-patterns are represented as a new pattern variant:

pub enum Pattern {
    // ... existing variants ...
    Or(Vec<Pattern>),
}

Each element of the vector is an alternative pattern. The or-pattern matches if any alternative matches.

Or-Pattern Parsing

The parser collects alternatives separated by | within a match arm:

fn parse_match_arm_pattern(&mut self) -> Result<Pattern, ParseError> {
    let first = self.parse_single_pattern()?;

if self.check(&Token::Pipe) { let mut alternatives = vec![first]; while self.match_token(&Token::Pipe) { alternatives.push(self.parse_single_pattern()?); } Ok(Pattern::Or(alternatives)) } else { Ok(first) } } ```

Note that | in pattern context means "or-pattern," while | in type context means "union type," and |> means "pipeline." The parser disambiguates by context: inside a match arm before ->, it is a pattern. In a type annotation, it is a union. After a value expression, it is a pipeline.

Type Checking Or-Patterns

The type checker validates that all alternatives in an or-pattern bind the same variables with compatible types:

fn check_or_pattern(&mut self, patterns: &[Pattern], value_type: &FlinType) {
    if patterns.is_empty() { return; }

// Collect bindings from first alternative let first_bindings = self.collect_pattern_bindings(&patterns[0], value_type);

// Check all other alternatives have the same bindings for (i, pattern) in patterns.iter().enumerate().skip(1) { let bindings = self.collect_pattern_bindings(pattern, value_type);

// Same names must be bound for (name, flin_type) in &first_bindings { match bindings.get(name) { Some(other_type) => { if !self.types_compatible(flin_type, other_type) { self.report_error(&format!( "or-pattern alternative {} binds '{}' as {} but alternative 1 binds it as {}", i + 1, name, other_type.display_name(), flin_type.display_name() )); } } None => { self.report_error(&format!( "or-pattern alternative {} does not bind '{}'", i + 1, name )); } } } } } ```

This validation ensures that the arm body can use any bound variable regardless of which alternative matched. If Circle(r) | Square(r) is an or-pattern, both alternatives must bind r, and both must bind it with compatible types.

Or-Patterns with Enum Variants

Or-patterns combine naturally with tagged unions:

enum Shape {
    Circle(number),
    Square(number),
    Rectangle(number, number),
    Triangle(number, number, number),
    Point
}

match shape { Circle(size) | Square(size) -> { print("Simple shape with size: " + text(size)) } Rectangle(w, h) -> { print("Rectangle: " + text(w) + " x " + text(h)) } Triangle(a, b, c) -> { print("Triangle with sides: " + text(a) + ", " + text(b) + ", " + text(c)) } Point -> print("Point") } ```

The Circle(size) | Square(size) pattern matches either variant and binds the inner value to size. The arm body does not need to know which variant matched -- it only cares about the size.

Or-Patterns and Exhaustiveness

Or-patterns participate in exhaustiveness checking. Each alternative in an or-pattern covers its respective variant:

enum Result<T, E> {
    Ok(T),
    Err(E),
    Pending
}

match result { Ok(v) | Pending -> handle_optimistic(v) // ERROR: Pending does not bind v } ```

This error highlights an important constraint: if one alternative binds a variable, all must. You cannot use Ok(v) | Pending because Pending does not carry data and cannot bind v.

The correct pattern:

match result {
    Ok(v) -> handle_success(v)
    Err(e) -> handle_error(e)
    Pending -> handle_pending()
}

Or, if the body does not need the bound value:

match result {
    Ok(_) | Pending -> print("not an error")
    Err(e) -> handle_error(e)
}

Wildcards (_) do not create bindings, so Ok(_) | Pending is valid -- neither alternative binds any variables.

Code Generation for Or-Patterns

Or-patterns compile to a series of pattern checks with a shared body:

fn emit_or_pattern_check(&mut self, patterns: &[Pattern]) -> usize {
    let mut success_jumps = vec![];

for (i, pattern) in patterns.iter().enumerate() { let match_result = self.emit_pattern_check(pattern); if i < patterns.len() - 1 { // If this alternative matches, jump to the body success_jumps.push(self.emit_jump_if_true()); } }

// Last alternative: if it does not match, skip the arm let skip_jump = self.emit_jump_if_false();

// Patch all success jumps to here (before the body) for jump in success_jumps { self.patch_jump(jump); }

skip_jump } ```

Each alternative is checked in sequence. If any matches, execution falls through to the arm body. Only if all alternatives fail does the arm's body get skipped.

Combining Both Features

Labeled loops and or-patterns can appear in the same code:

'search: for row in grid {
    for cell in row {
        match cell.status {
            "found" | "confirmed" -> {
                result = cell
                break 'search
            }
            "blocked" | "invalid" -> continue
            _ -> process(cell)
        }
    }
}

The or-pattern "found" | "confirmed" combines two status values into one arm. The labeled break break 'search exits the outer loop when a match is found. The or-pattern "blocked" | "invalid" skips processing for those statuses. Three features -- or-patterns, labeled loops, and break with value -- working together.

Implementation Statistics

Sessions 154-155 modified the following files:

FileChanges
src/parser/ast.rsLabel fields on loops, break, continue; Pattern::Or
src/parser/parser.rsLabel parsing, or-pattern parsing
src/typechecker/checker.rsLoop label stack, or-pattern binding validation
src/codegen/emitter.rsLabeled jump resolution, or-pattern code generation
src/fmt/formatter.rsLabel and or-pattern formatting
tests/integration_e2e.rsNew tests for both features

Both features were additive -- no existing tests were modified, and all existing tests continued to pass.

Why These Features Together

Labeled loops and or-patterns might seem unrelated, but they share a common goal: reducing the gap between what the developer wants to express and what the language syntax allows them to express.

Without labeled loops, the developer wants to say "exit both loops" but must use a flag variable. Without or-patterns, the developer wants to say "handle these cases the same way" but must duplicate the arm body. Both features close an expressiveness gap.

FLIN's development philosophy is to identify these gaps and close them with minimal, composable features. Labeled loops add a label to existing loops and a target to existing breaks. Or-patterns add a | to existing patterns. Neither changes the language's semantics -- both are reducible to existing constructs. But both dramatically improve the developer experience.

---

This is Part 44 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and implemented a programming language from scratch.

Series Navigation: - [42] Generic Bounds and Where Clauses - [43] While-Let Loops and Break With Value - [44] Labeled Loops and Or-Patterns (you are here) - [45] Advanced Type Features: The Complete Picture

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles