Skip to content

Transfers

return, error, postfix ?, break, and continue — where each one lands and what cleanup it runs.

Updated View as Markdown

A transfer abandons the current expression and resumes at a surrounding control boundary. The form decides both the target and the value carried there.

Form Target Payload
return value enclosing function or closure success value
error value enclosing fallible function or closure error value
unsuccessful postfix ? enclosing compatible function or closure None or Err(err)
break value nearest or labeled loop optional loop result
continue nearest or labeled loop none

A transfer has no value at its own position, so it never constrains the type of a sibling branch.

Return

return evaluates its operand, leaves the current function or closure, runs cleanup for every scope it exits, and hands the value to the caller.

fn classify(value: int): string {
    if value < 0 {
        return "negative"
    }
    if value == 0 {
        return "zero"
    }
    return "positive"
}

A bare return is valid when the success type is void:

fn run(ready: bool): void {
    if !ready {
        return
    }
    println("go")
}

A tail expression or an expression-bodied function is clearer for a single final value; use explicit return for early exits and when several completion paths should be easy to compare.

fn double(value: int): int => value * 2

return inside a closure returns from that closure invocation, not from the function that created it:

fn clamp_all(values: []int): []int {
    return values.map(value => {
        if value < 0 {
            return 0
        }
        value * 2
    })
}

The value must match the declared success type:

fn f(): int {
    return "nope"
}

Error

error value leaves through the function’s error channel.

error InputError { NotPositive { value: int }, TooLarge }

fn require_positive(value: int): int ! InputError {
    if value <= 0 {
        error NotPositive { value: value }
    }
    if value > 1000 {
        error TooLarge
    }
    return value
}

The variant name is enough when the function’s error type makes it unambiguous. The payload must belong to the declared error type or a compatible union:

error AuthError { Denied }
error StorageError { Full }

fn f(): int ! AuthError {
    error Full
}

error is a keyword throughout the language, so error payloads are bound as err or e, never error.

Postfix ?

? is a conditional transfer. It unwraps Some/Ok, and on None/Err it leaves through the enclosing boundary immediately.

error LoadError { NotFound }

fn load(id: int): int ! LoadError {
    if id < 0 { error NotFound }
    return id
}

fn total(a: int, b: int): int ! LoadError {
    return load(a)? + load(b)?
}

It works the same way for Option in an Option-returning function:

fn head(values: []int): int? {
    first := values.get(0)?
    return Some(first * 2)
}

The boundary is lexical and must be compatible. ? on an Option inside a function that returns a plain int has nowhere to go:

fn maybe(): int? { return None }

fn f(): int {
    value := maybe()?
    return value
}

Break

break exits the nearest enclosing loop.

fn count_until_zero(values: []int): int {
    mut seen := 0
    for value in values {
        if value == 0 {
            break
        }
        seen += 1
    }
    return seen
}

break value makes the loop produce a result. Every value-bearing break targeting the same loop must agree on a type:

fn f(): int {
    mut index := 0
    result := for {
        if index > 3 { break 1 }
        break "two"
    }
    return result
}

Because an iteration or condition loop can also finish through its head — a path that contributes void — the bare for { } form is the clearest shape for a value search. See Loops.

break outside any loop is rejected:

fn f(): int {
    break
    return 0
}

Continue

continue ends the current iteration after running cleanup for the scopes it leaves, then resumes at the loop head.

struct Item { id: int, valid: bool }

fn consume(items: []Item): int {
    mut used := 0
    for item in items {
        if !item.valid {
            continue
        }
        used += item.id
    }
    return used
}

An iteration loop advances its iterator, a condition loop rechecks its condition, and a pattern-consume loop re-evaluates and re-matches its head.

Labels

A label names an enclosing loop so break and continue can target it past the innermost one. The label is written 'name: at the loop and 'name at the use.

fn first_terminal(rows: [][]int): int {
    mut visited := 0
    'rows: for row in rows {
        for cell in row {
            if cell < 0 {
                break 'rows
            }
            visited += 1
        }
    }
    return visited
}

continue 'label starts the next iteration of the labeled loop, leaving every inner scope first:

fn count_clean(rows: [][]int): int {
    mut clean := 0
    'rows: for row in rows {
        for cell in row {
            if cell < 0 {
                continue 'rows
            }
        }
        clean += 1
    }
    return clean
}

A label is not a character literal and not a general jump target — it must name an enclosing loop:

fn f(values: []int): int {
    for value in values {
        break 'nowhere
    }
    return 0
}

Cleanup

No transfer bypasses lexical ownership. Every exit follows the same sequence:

  1. evaluate the transfer’s payload, if any;
  2. stop evaluating the rest of the expression;
  3. run defers and managed destruction for the exited scopes, innermost first;
  4. resume at the function or loop target.
error IoError { Denied }

struct File { id: int }

impl File {
    fn open(path: string): File ! IoError {
        if path.is_empty() { error Denied }
        return File { id: path.len() }
    }
    fn read_line(self): string ! IoError { return "line" }
    fn close(self): void { println("closed ${self.id}") }
}

fn read_first(path: string): string? ! IoError {
    file := File.open(path)?
    defer file.close()

    line := file.read_line()?
    if line.is_empty() {
        return None
    }
    return Some(line)
}

The file closes on the propagated ?, on the return None, and on the return Some(line) — after the return value has been evaluated, so the return expression can still read the resource it is about to release.

A transfer from a nested loop leaves fewer scopes than a function return does, so identify the exact target before reasoning about which cleanup runs.

Diagnostics

The compiler rejects:

  • break or continue outside a loop;
  • a label with no matching enclosing loop;
  • a break payload incompatible with the target loop;
  • a return value incompatible with the function’s success type;
  • an error value incompatible with the function’s error type;
  • postfix ? without a compatible optional or fallible boundary.

These are static target and type errors. Whether a given transfer is reached at runtime is ordinary control flow and is not analysed.

Putting it together

error SyncError {
    Unreachable { host: string }
    Rejected { code: int }
}

struct Batch { host: string, items: []int }

fn send(host: string, item: int): int ! SyncError {
    if host.is_empty() { error Unreachable { host: host } }
    if item < 0 { error Rejected { code: item } }
    return item
}

fn note(message: string): void { println(message) }

fn sync(batches: []Batch, budget: int): int ! SyncError {
    mut spent := 0

    'batches: for batch in batches {
        defer note("finished ${batch.host}")

        if batch.items.is_empty() {
            continue
        }

        for item in batch.items {
            if spent >= budget {
                break 'batches
            }
            if item == 0 {
                continue
            }

            accepted := match send(batch.host, item) {
                Ok(value) => value
                Err(err) => continue 'batches
            }
            spent += accepted
        }
    }

    if spent == 0 {
        error Rejected { code: 0 }
    }
    return spent
}

Five different exits share one function: continue skips an item, continue 'batches abandons a whole batch after a failure, break 'batches stops on budget, error leaves through the error channel, and return completes it. Every one of them runs the per-batch defer for each scope it leaves.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close