Skip to content

Language Guide: A Tour of Sushi

← Back to Documentation

This guide provides a friendly tour of Sushi's features. If you're new to Sushi, start here before diving into the detailed Language Reference.

Table of Contents

Hello World

Every Sushi program starts with a main function that serves as the entry point for execution:

fn main() i32:
    println("Mostly Harmless")
    return Result.Ok(0)

Key points: - fn declares a function - i32 is the return type (32-bit integer) - All functions actually return Result@(T) - this is Sushi's approach to explicit error handling (more on this later) - println outputs text with a newline to standard output - The return Result.Ok(0) convention indicates successful program termination (exit code 0)

The main function must return an i32 (which the operating system uses as the exit code), and like all Sushi functions, it must explicitly wrap this value in Result.Ok() to indicate successful execution.

Variables and Types

Declaration and Rebinding

Sushi uses explicit variable declarations with type annotations for clarity and compile-time safety:

fn main() i32:
    # Declare with let
    let i32 answer = 42
    let string question = "Ultimate Question"

    # Rebind with :=
    answer := 54  # Wrong answer!

    return Result.Ok(0)

Important: Variables must be declared with let before you can rebind them with :=. This two-step approach makes it clear when a variable is first introduced versus when its value is being changed.

The declaration syntax follows the pattern let <type> <name> = <value>, where the type comes before the variable name. This makes it easy to scan code and immediately see what types are being used. Once declared, you can reassign values using the := operator, which indicates mutation rather than declaration.

Numeric Types

Sushi has explicit numeric types:

fn main() i32:
    # Signed integers
    let i8 tiny = 127
    let i32 normal = 2147483647
    let i64 huge = 9223372036854775807

    # Unsigned integers
    let u8 byte = 255
    let u32 unsigned = 4294967295

    # Floating-point
    let f32 pi = 3.14
    let f64 precise = 3.141592653589793

    return Result.Ok(0)

Integer Overflow

A value has the width its type gives it, and the compiler tells you when a value you wrote cannot fit that width. It reads what it can read: a const, and an expression built from literals and constants. An operation whose result the type cannot hold is a compile error (CE2077), so a wrong answer never reaches the program:

fn main() i32:
    let u8 sum = 200 + 100     # CE2077: '+' gives 300, out of range for u8
    println(sum)
    return Result.Ok(0)

The answer is a wider type, or an as cast when the bit pattern is what you want: (200 as u16) + 100 counts to 300 in a u16, and 300 as u8 keeps the low eight bits and gives 44. The checked operators are +, -, *, /, % and unary minus. The bitwise operators ask a different question, because they move and combine bits: &, |, ^, ~, << and >> work at the width and lose whatever leaves it, so 200 << 1 on a u8 is 144 and nothing reports it.

Nothing is checked at run time. The compiler cannot read the value of a variable, so a sum of two locals wraps at the width, as it does in C:

fn main() i32:
    let u8 a = 200
    let u8 b = 100
    let u8 sum = a + b
    println(sum)               # 44
    return Result.Ok(0)

Type Conversion

All conversions must be explicit with as:

fn main() i32:
    let i32 x = 42
    let f64 y = x as f64      # int to float
    let u32 z = y as u32      # float to unsigned int

    return Result.Ok(0)

Strings

Strings have full UTF-8 support:

use <collections/strings>

fn main() i32:
    let string text = "Don't Panic"
    let string emoji = "🐬 🌍"

    println(text)
    println("Length: {text.len()}")     # Character count
    println("Size: {text.size()}")      # Byte count

    return Result.Ok(0)

String methods: - .len() -> i32 - Character count (UTF-8 aware) - .size() -> i32 - Byte count - .is_empty() -> bool - Check if string is empty - .find(string) -> Maybe@(i32) - Find substring position - .split(string) -> string[] - Split into array by delimiter - .trim() -> string - Remove leading/trailing whitespace - .to_upper() -> string - Convert to uppercase (ASCII only) - .to_lower() -> string - Convert to lowercase (ASCII only)

See Standard Library: String Methods for detailed documentation.

String Interpolation

Embed expressions in strings with {...}:

fn main() i32:
    let i32 answer = 42
    let string name = "Arthur"

    println("Hello {name}, the answer is {answer}")
    println("Next answer: {answer + 1}")

    return Result.Ok(0)

Functions and Returns

Functions are the building blocks of Sushi programs. Every function follows a consistent pattern: explicit parameter types, explicit return type, and mandatory Result@(T) wrapping for all returns.

Basic Functions

fn add(i32 a, i32 b) i32:
    return Result.Ok(a + b)

fn greet(string name) ~:  # ~ is "blank" type (no return value)
    println("Hello, {name}!")
    return Result.Ok(~)

fn main() i32:
    let i32 sum = add(5, 7).realise(0)
    greet("Ford")
    return Result.Ok(0)

Function syntax breakdown: - fn keyword declares a function - Parameters: type name pairs, comma-separated - Return type: comes after the parameter list - ~ ("blank" or "unit" type): used for functions that don't return a meaningful value - Body: indented block following the colon - Returns: must be Result.Ok(value) or Result.Err()

The blank type (~): When a function performs an action but doesn't produce a value (like printing or modifying a reference), it returns ~. You must still wrap it: return Result.Ok(~). This maintains consistency with Sushi's error handling model.

Multiple Parameters

fn describe(string name, i32 age, bool friendly) ~:
    println("{name} is {age} years old")
    if (friendly):
        println("They're quite friendly!")
    return Result.Ok(~)

fn main() i32:
    describe("Zaphod", 200, false)
    return Result.Ok(0)

Parameter passing: every parameter declares a mode, and the mode says who frees the value. A marked mode is written at the declaration and at the call site alike; the default is written at neither.

  • unmarkedfn f(string x), called f(s). A borrow: the caller keeps the value and frees it, so s stays usable after the call. This is the default for every type.
  • nomfn f(nom string x), called f(nom s). A consume: the callee becomes the owner and frees the value, so using s afterwards is CE2405 (use-after-move).
  • peekfn f(peek string x), called f(peek s). A read-only borrow by pointer; many at once.
  • pokefn f(poke string x), called f(poke s). A read-write borrow by pointer, so the callee's writes reach the caller's value; one at a time, exclusive.

Every struct and enum gets an auto-derived .clone(), which is how a caller hands over a value it wants to keep: f(nom s.clone()).

(One special case: main's string[] args is a borrowed view of the process argv. Passing it to an ordinary borrow parameter is fine; handing it to a nom one is CE2410.)

Documenting Code

A documentation block is part of the declaration, not a comment near it. The compiler reads it, and checks what it says against the declaration beside it.

A block opens with ##: and closes with :##:

##: The answer to life, the universe and everything. :##
const i32 ANSWER = 42

##:
Adds two numbers.

- Parameter a: The first addend.
- Parameter b: The second addend.
- Returns: The sum.
- Errors: Never, in practice.
:##
fn add(i32 a, i32 b) i32:
    return Result.Ok(a + b)

fn main() i32:
    let i32 sum = add(ANSWER, 1).realise(0)
    println("{sum}")
    return Result.Ok(0)

The block attaches to the declaration on the next line. A blank line breaks the attachment, and so does an ordinary # comment. A block that attaches to nothing warns, unless it is the first item in the file — that one documents the unit.

A block may also stand first in a body, where it documents the function around it:

fn make_tea(u8 strength) ~:
    ##:
    Makes a cup of tea. A block that is first in a body documents the function.
    :##
    println("{strength}")
    return Result.Ok(~)

fn main() i32:
    make_tea(7 as u8)
    return Result.Ok(0)

Four tags are recognised, and each one is an ordinary Markdown list item: - Parameter <name>:, - Returns:, - Errors: and - Example:. Everything else is prose, and the first paragraph is the summary.

Because the compiler owns the block, it can tell you that a - Parameter q: names no parameter of this function, that one parameter is documented twice, or that - Retruns: is a misspelling of - Returns:. A misspelled tag would be silently invisible in a system that reads the block as text.

An - Example: introduces a fenced code block, and python tests/docs_sweep.py compiles and runs it: an example that stops compiling is documentation that has drifted.

Those checks are always on, because each one finds a claim that contradicts the declaration. What is simply MISSING is a matter of policy, so it waits for a flag: compile with ./sushic --warn-missing-docs and the compiler names every declaration, parameter, return value, error arm and unit with no documentation.

See Documentation Blocks for the full reference.

Control Flow

If-Elif-Else

fn main() i32:
    let i32 panic_level = 7

    # Parentheses required around conditions
    if (panic_level > 10):
        println("Serious panic!")
    elif (panic_level > 5):
        println("Mild panic")
    else:
        println("Stay calm")

    return Result.Ok(0)

While Loops

fn main() i32:
    let i32 countdown = 5

    while (countdown > 0):
        println("T-minus {countdown}")
        countdown := countdown - 1

    println("Liftoff!")
    return Result.Ok(0)

For-Each Loops

fn main() i32:
    let string[] names = from(["Arthur", "Ford", "Trillian"])

    foreach(name in names.iter()):
        println("Passenger: {name}")

    return Result.Ok(0)

Break and Continue

fn main() i32:
    let i32 x = 0

    while (x < 10):
        x := x + 1

        if (x == 3):
            continue  # Skip 3

        if (x == 8):
            break  # Stop at 8

        println(x)

    return Result.Ok(0)

Error Handling

Result@(T)

Sushi uses Result@(T) as its fundamental approach to error handling. Every function in Sushi implicitly returns a Result@(T) type, even if you declare the return type as just T. This design choice eliminates entire classes of bugs by making error handling explicit and impossible to ignore.

The Philosophy: In many languages, functions can fail silently or throw exceptions that might not be handled. Sushi puts the failure in the type instead: if a function can fail, that failure is part of what it returns, so the compiler can tell you where you have not dealt with it. You must explicitly choose to handle errors or propagate them.

fn divide(i32 a, i32 b) i32:
    if (b == 0):
        return Result.Err(StdError.Error)  # Error case
    return Result.Ok(a / b)  # Success case

fn main() i32:
    let Result@(i32, StdError) result = divide(42, 6)

    # Check if successful
    if (result.is_ok()):
        let i32 value = result.realise(0)
        println("Result: {value}")
    else:
        println("Division failed")

    return Result.Ok(0)

Key Concepts: - When you declare a function returning i32, it actually returns Result@(i32) - Success values must be wrapped: return Result.Ok(value) - Failures are signaled with: return Result.Err(StdError.Error) - A condition is a bool and nothing else, so a Result is tested with .is_ok() or .is_err(); if (result) on its own is CE2516 - The .realise(default) method extracts the value, using the default if the result is an error

This approach eliminates null pointer exceptions and ensures that error cases are always visible in the code. The compiler enforces that you handle or propagate errors - you cannot accidentally ignore them.

Unwrapping with .realise()

The .realise(default) method provides a safe way to extract values from Result@(T), similar to unwrapping in other languages but with a mandatory fallback value:

fn get_value() i32:
    return Result.Ok(42)

fn main() i32:
    # Unwrap with default value
    let i32 x = get_value().realise(0)
    println("Value: {x}")

    return Result.Ok(0)

The name "realise" reflects the operation of "making the value real" by extracting it from the Result wrapper. Unlike unsafe unwrap operations in other languages, .realise() always requires a default value, ensuring your code never crashes from unwrapping an error state.

Common patterns: - Numeric defaults: .realise(0) or .realise(-1) - String defaults: .realise("") for empty strings - Boolean defaults: .realise(false) for failure states - Using the default to indicate error: let i32 result = compute().realise(-1) where -1 signals failure

Error Propagation (?? operator)

The ?? operator provides elegant error propagation - it unwraps successful results and automatically returns errors to the caller. This is one of Sushi's most powerful features for writing clean error-handling code:

use <io/files>
use <io/fs>

fn read_config() string | IoError:
    # If open fails, ?? returns Err immediately
    let File f = open("config.txt", FileMode.Read())??

    # Only reaches here if open succeeded
    let string content = f.read_all()??
    f.close()??
    return Result.Ok(content)

fn main() i32:
    let Result@(string, IoError) config = read_config()
    # Handle config...
    return Result.Ok(0)

How it works: 1. If the Result is Ok(value), ?? extracts and returns the value 2. If the Result is Err(), ?? immediately returns Result.Err() from the current function 3. The error propagates up the call stack until someone handles it

RAII Safety: The ?? operator is fully integrated with Sushi's RAII (Resource Acquisition Is Initialization) system. When an error is propagated, all resources in the current scope are properly cleaned up before the function returns. This means you never leak memory or file handles when errors occur.

Advantages over manual error checking:

use <io/fs>

# Without ??: verbose, and there is no default handle to fall back on
fn process() string | IoError:
    match open("data.txt", FileMode.Read()):
        Result.Ok(f) ->
            match f.read_all():
                Result.Ok(data) -> return Result.Ok(data)
                Result.Err(e) -> return Result.Err(e)
        Result.Err(e) ->
            return Result.Err(e)

# With ??: clean and safe
fn process() string | IoError:
    let File f = open("data.txt", FileMode.Read())??
    return Result.Ok(f.read_all()??)

The ?? operator makes error handling code read almost like non-error-handling code, while maintaining full safety and explicit error propagation.

Maybe@(T) for Optional Values

Maybe@(T) is Sushi's type-safe way to represent values that might or might not exist. It replaces the dangerous practice of using sentinel values (like -1, null, or empty strings) to indicate "no value". With Maybe@(T), the absence of a value is encoded in the type system, making it impossible to forget to check.

The Problem with Sentinel Values: In many languages, you might return -1 to indicate "not found" or use null to mean "no value". This leads to bugs because: - The sentinel value might be a valid result (what if -1 is actually in your data?) - You can forget to check for the sentinel value - Different functions might use different sentinel values

The Maybe Solution: Maybe@(T) has two variants: - Maybe.Some(value) - contains a value of type T - Maybe.None() - represents the absence of a value

fn find_first_even(i32[] numbers) Maybe@(i32):
    foreach(n in numbers.iter()):
        if (n % 2 == 0):
            return Result.Ok(Maybe.Some(n))
    return Result.Ok(Maybe.None())

fn main() i32:
    let i32[] data = from([1, 3, 5, 8])
    # Functions return Result@(T), so find_first_even returns Result@(Maybe@(i32))
    let Result@(Maybe@(i32), StdError) result = find_first_even(data)

    match result:
        Result.Ok(found) ->
            match found:
                Maybe.Some(value) ->
                    println("Found: {value}")
                Maybe.None() ->
                    println("No even numbers")
        Result.Err(_) ->
            println("Search failed")

    return Result.Ok(0)

Key Methods on Maybe@(T): - .is_some() - returns true if the Maybe contains a value - .is_none() - returns true if the Maybe is empty - .realise(default) - extracts the value or returns the default if None - .expect(message) - extracts the value or terminates with an error message

Common Use Cases: - Search operations (return Maybe.Some(index) if found, Maybe.None() if not) - Optional configuration values (return Maybe.Some(config) or Maybe.None()) - Nullable references in data structures (use Maybe@(T) instead of trying to represent null) - HashMap lookups (.get() returns Maybe@(V) since the key might not exist)

Composing Maybe with Result: Since functions return Result@(T), you often see Result@(Maybe@(T)) - a result that might be an error, or might be a success with an optional value. The type system helps you handle all cases correctly.

Collections

Sushi provides multiple collection types, each optimized for different use cases. Understanding when to use each type is key to writing efficient code.

Arrays

Sushi has two array types: fixed-size arrays allocated on the stack, and dynamic arrays that can grow and shrink:

fn main() i32:
    # Fixed-size array
    let i32[3] fixed = [1, 2, 3]

    # Dynamic array
    let i32[] dynamic = from([1, 2, 3])
    dynamic.push(4)
    dynamic.push(5)

    println("Length: {dynamic.len()}")

    # Iteration
    foreach(n in dynamic.iter()):
        println(n)

    return Result.Ok(0)

Fixed vs Dynamic: - Fixed arrays (T[N]): Size known at compile time, allocated on the stack, cannot be resized. Fast and lightweight. - Dynamic arrays (T[]): Size can change at runtime, heap-allocated, supports push/pop operations. The from([...]) function converts a fixed array literal to a dynamic array.

Array methods: .len(), .get(), .push(), .pop(), .clone(), .iter(), .hash()

Writing one element: arr[i] := value works on both array kinds and on every element type. The index is bounds-checked exactly like a read, so an index past the end aborts with RE2020 (and a literal index is rejected at compile time: CE2012 past the end of a fixed array, CE2056 if it is negative):

fn main() i32:
    let i32[3] fixed = [1, 2, 3]
    fixed[0] := 42

    let string[] words = from(["towel", "guide"])
    words[1] := "babel fish"        # the old element is freed, the new one adopted

    println("{fixed[0]} {words[1]}")
    return Result.Ok(0)

An indexed assignment takes ownership of the value, so the rules are the ones every other owning position uses: an owned source is moved (using it afterwards is CE2405), and a value read out of a container needs .clone() (words[0] := words[1] is CE2411). You may only write where the write can reach the owner — not through a peek parameter, a match binding, or a constant.

Elements that fill more than one slot: a table does not have to be spelled out. value; count fills count slots with one value, and a range fills the slots it spans. Both mix with plain elements:

fn main() i32:
    let i32[10] tally = [0; 10]           # ten zeros
    let i32[6]  mixed = [1, 0;3, 9, 7]    # 1 0 0 0 9 7
    let i32[6]  table = [0..=5]           # 0 1 2 3 4 5
    let i32[]   head  = from([-1; 1000])  # a thousand, on the heap

    println("{tally[9]} {mixed[2]} {table[5]} {head.len()}")
    return Result.Ok(0)

Where the count must be readable depends on the position. A fixed array's length is part of its type and a constant needs its values, so both need a count the compiler can read. A from() array carries its length at run time, so a count or a bound there may be any i32 expression:

fn main() i32:
    let i32 n = 4
    let i32[] slots = from([0; n])        # four zeros, sized at run time
    let i32[] index = from([0..n])        # 0 1 2 3

    println("{slots.len()} {index[3]}")
    return Result.Ok(0)

The repeated value is a borrow, and every slot takes its own copy, so a string works and the source stays yours. A range yields i32. See the Language Reference for the full rules.

Memory Management: Dynamic arrays use RAII - they're automatically deallocated when they go out of scope. The destructor recursively cleans up all elements, so arrays of structs or nested arrays are properly freed. Arrays use move semantics: when you pass a dynamic array to a function, ownership transfers unless you explicitly .clone() it.

List@(T)

List@(T) is a generic growable collection that provides more flexibility than raw dynamic arrays. It's similar to Vec@(T) in Rust or ArrayList@(T) in Java:

fn main() i32:
    let List@(string) passengers = List.new()

    passengers.push("Arthur")
    passengers.push("Ford")
    passengers.push("Trillian")

    println("Passengers: {passengers.len()}")

    let Maybe@(string) first = passengers.get(0)
    match first:
        Maybe.Some(name) -> println("First: {name}")
        Maybe.None() -> println("Empty list")

    return Result.Ok(0)

Key Features: - Generic: Works with any type - List@(i32), List@(string), List@(MyStruct), etc. - Automatic growth: Capacity doubles when full, giving amortized O(1) push operations - Safe access: .get() returns Maybe@(T) instead of crashing on invalid indices - Efficient: Uses llvm.memmove for shifting elements during insert/remove operations

List methods: - Creation: .new(), .with_capacity(n) - Access: .get(index), .len(), .capacity(), .is_empty() - Modification: .push(value), .pop(), .insert(index, value), .remove(index), .clear() - Memory: .reserve(additional), .shrink_to_fit(), .free(), .destroy() - Iteration: .iter(), .debug()

When to use List vs raw arrays: Use List@(T) when you need frequent insertions/removals at arbitrary positions, capacity management, or want the additional safety of Maybe@(T) returns. Use raw dynamic arrays (T[]) for simpler use cases where you just need push/pop at the end.

HashMap@(K, V)

HashMap@(K, V) provides O(1) average-case key-value lookups using open addressing with linear probing:

use <collections/hashmap>

fn main() i32:
    let HashMap@(string, i32) ages = HashMap.new()

    ages.insert("Arthur", 42)
    ages.insert("Ford", 200)
    ages.insert("Trillian", 30)

    let Maybe@(i32) age = ages.get("Arthur")
    match age:
        Maybe.Some(a) -> println("Arthur is {a}")
        Maybe.None() -> println("Not found")

    return Result.Ok(0)

Implementation Details: - Open addressing: Uses linear probing instead of chaining, giving better cache locality - Automatic resizing: Grows at 0.75 load factor to maintain performance - Power-of-two capacity: Allows fast modulo using bitwise AND operations - Auto-derived hashing: Any type with a .hash() method can be used as a key

HashMap methods: .new(), .insert(key, value), .get(key), .remove(key), .contains_key(key), .len(), .keys(), .values(), .entries(), .debug(), .free()

Memory Management: HashMaps use recursive destruction - when you call .free() or when the HashMap goes out of scope, it destroys all entries and their contents. This works correctly even for complex value types like structs containing arrays or nested enums.

Limitations: Keys must implement .hash() (auto-derived for most types).

Structs and Enums

Structs and enums are Sushi's primary ways to create custom data types. Structs group related data together, while enums represent types that can be one of several variants.

Defining Structs

Structs are product types - they contain multiple fields simultaneously:

struct Person:
    string name
    i32 age
    bool friendly

fn main() i32:
    let Person arthur = Person(name: "Arthur", age: 42, friendly: true)

    println("{arthur.name} is {arthur.age} years old")

    # Modify fields
    arthur.age := 43

    return Result.Ok(0)

Key features: - Named field initialization: Use StructName(field: value, ...) syntax for clarity and order-independence - Positional also supported: Person("Arthur", 42, true) for brevity - Named parameters prevent "boolean trap" bugs in structs with multiple bool fields - Cannot mix positional and named (all-or-nothing) - Field access: Use dot notation struct.field to read or modify fields - RAII cleanup: When a struct goes out of scope, all its fields are recursively destroyed - Auto-derived hashing: Structs automatically get a .hash() method combining all field hashes

Common patterns: - Data transfer objects: Group related data for passing between functions - Configuration: Struct fields for application settings - Complex state: Multiple related values that belong together

Defining Enums

Enums are sum types - a value can be exactly one variant at a time:

enum Status:
    Ready()
    Working(i32)
    Done()

fn main() i32:
    let Status current = Status.Working(75)

    match current:
        Status.Ready() ->
            println("Ready to start")
        Status.Working(progress) ->
            println("Progress: {progress}%")
        Status.Done() ->
            println("Completed")

    return Result.Ok(0)

Key features: - Variants with data: Each variant can contain different types of associated data - Type safety: The compiler ensures you handle all variants through exhaustive pattern matching - Memory efficient: Enums use a discriminant tag plus space for the largest variant - RAII for complex variants: If a variant contains a struct or array, it's properly cleaned up

How enums are implemented: Sushi enums compile to a struct containing: 1. A discriminant (tag) indicating which variant is active 2. Storage for the variant data (union of all variant types)

When you pattern match, the compiler generates a switch on the discriminant, then extracts and interprets the variant data appropriately.

Common use cases: - State machines: Represent different states with different associated data - Error types: Different error variants with relevant information - Optional complex data: Use Maybe@(T) (which is an enum) for values that might not exist - Algebraic data types: Build sophisticated recursive data structures

Pattern Matching

Pattern matching is Sushi's way of deconstructing enums and handling different cases. The compiler enforces exhaustiveness checking - you must handle all possible variants, ensuring you never forget a case.

Match Expressions

enum Response:
    Success(i32)
    Error(string)

fn handle(Response resp) ~:
    match resp:
        Response.Success(code) ->
            println("Success code: {code}")
        Response.Error(msg) ->
            println("Error: {msg}")

    return Result.Ok(~)

fn main() i32:
    handle(Response.Success(200))
    handle(Response.Error("Not found"))
    return Result.Ok(0)

How matching works: 1. The match keyword examines an enum value 2. Each arm pattern specifies a variant: EnumName.VariantName(bindings) 3. If the pattern matches, the associated data is extracted and bound to variables 4. The code after -> executes for the matching pattern 5. The compiler verifies all variants are covered

Exhaustiveness checking: If you add a new variant to Response, any match expressions that don't handle it will cause compilation errors. This prevents bugs from forgetting to handle new cases.

Nested Patterns

Patterns can be nested to match complex enum structures in a single expression:

use <io/fs>

fn handle_file(Result@(File, IoError) result) ~:
    match result:
        Result.Ok(poke f) ->
            println("File opened successfully")
        Result.Err(FileError.NotFound()) ->
            println("File not found")
        Result.Err(FileError.PermissionDenied()) ->
            println("Permission denied")
        Result.Err(_) ->
            println("Other error")

    return Result.Ok(~)

Nested pattern matching: The pattern Result.Err(FileError.NotFound()) matches a Result@(File, IoError) whose Err variant contains a FileError enum with the NotFound variant. This lets you handle specific error combinations without nested match statements.

Wildcard patterns: The _ pattern matches anything, acting as a catch-all for remaining cases. It's useful for handling "all other errors" or "default" cases.

Zero-cost compilation: Pattern matching compiles to efficient jump tables or switch statements. There's no runtime overhead compared to hand-written if-else chains or switch statements in C.

Binding Modes

A payload binding says what it does with the value, and it says it the way a parameter does. A bare binding BORROWS -- it reads, and it is the case almost every arm wants.

enum Box:
    Rows(i32[])
    Empty

fn take(nom i32[] xs) ~:
    println("took {xs.len()}")
    return Result.Ok(~)

fn make() Box:
    return Result.Ok(Box.Rows(from([1, 2, 3])))

fn main() i32:
    match make()??:
        Box.Rows(r) -> println("read {r.len()}")     # a borrow: read only
        Box.Empty -> println("empty")

    match make()??:
        Box.Rows(poke r) -> r.push(9)                # a pointer into the payload
        Box.Empty -> println("empty")

    match make()??:
        Box.Rows(nom r) -> take(nom r)               # the arm owns it now
        Box.Empty -> println("empty")

    return Result.Ok(0)

Taking needs a scrutinee the match owns. make()?? is a temporary: nothing else will ever free it, so the match may give it away. A local is different -- match r: leaves r the owner, and taking out of it would be a double free. Say match nom r: and the local is handed over, exactly as take(nom r) hands it over; r may not be read afterwards.

An arm takes the variant whole. If one binding in an arm is nom, every other owning payload of that variant must be nom as well -- what stops the match freeing the value is the whole scrutinee, not one slot of it.

Generics

Sushi's generics system provides zero-cost abstraction through compile-time monomorphization. Generic types are instantiated automatically based on usage, generating specialized code for each concrete type combination.

Generic Structs

struct Pair@(T, U):
    T first
    U second

fn main() i32:
    let Pair@(i32, string) data = Pair(first: 42, second: "answer")

    println("Number: {data.first}")
    println("Label: {data.second}")

    return Result.Ok(0)

How it works: The compiler detects that you use Pair@(i32, string) and generates a specialized version of the struct for that type combination. There's no runtime overhead - the generated code is as efficient as if you'd written a separate struct manually.

Generic Nesting: Sushi fully supports nested generics like Result@(Maybe@(T)), List@(Pair@(K, V)), or HashMap@(string, List@(i32)). The type system correctly handles arbitrarily deep nesting.

Extension Methods

Extension methods let you add functionality to existing types without modifying their definitions -- the same idea as extension methods in C#, Kotlin, or Swift. In Sushi, this is called UFCS (Uniform Function Call Syntax).

extend i32 squared() i32:
    return self * self

extend string shout() string:
    return "{self}!!!"

fn main() i32:
    let i32 x = 7
    println("Squared: {x.squared()}")

    let string msg = "Don't Panic"
    println(msg.shout())

    return Result.Ok(0)

How Extension Methods Work: 1. You define a function with extend <type> <method_name>(...) <return_type> 2. The first implicit parameter is self, referring to the value you're calling the method on 3. The call x.squared() is transformed at compile-time into squared(x) 4. This transformation is zero-cost - there's no runtime overhead

Static methods -- a method with no receiver: put static before the method name and the method is called on the TYPE, not on a value. This is how a type carries its own constructor.

struct Vec:
    i32 x
    i32 y

extend Vec static at(i32 x, i32 y) Vec:
    return Vec(x, y)

extend Vec length_squared() i32:
    return self.x * self.x + self.y * self.y

fn main() i32:
    let Vec v = Vec.at(3, 4)
    println("{v.length_squared()}")
    return Result.Ok(0)

Vec.at(3, 4) reads like List.new() and HashMap.new(), and it is the same rule: a name behind a type's dot is a member of that type. A static has no self -- naming one in the signature or in the body is CE0134 -- and everything else about it is an ordinary method: the parameter modes, the owning return, the | E channel, and no visibility marker of its own.

new is a legal static name (extend Box static new(i32 n) Box:), which is one thing a free function cannot be called.

No ?? in a BARE extension body: by default an extension method returns a bare value, not a Result@(T, E) (a Result.Ok(...) return is CE2091). A bare body has no error channel, so ?? has nothing to propagate into and is rejected with CE0131. Handle the Result in the body instead -- match on it, or use .realise(default):

extend i32 tagged() i32:
    return tag(self).realise(0)   # tag() returns Result@(i32, StdError)

The same rule applies to perk implementation methods. A ?? inside a LAMBDA in such a body is legal -- the lambda has its own Result channel:

extend i32 fixed() i32:
    let fn(i32) -> i32 f = |i32 x| tag(x)??   # legal: propagates into the lambda's Result
    return f(self).realise(0)

The opt-in error channel | E: a method that declares | E after its return type HAS a channel — the call yields Result@(T, E), ?? is legal in the body, and return Result.Err(e) is the one spelled constructor. The success still returns BARE (return Result.Ok(x) stays refused): the compiler wraps it for you.

enum OddError:
    TooOdd

extend i32 half_checked() i32 | OddError:
    if (self % 2 == 1):
        return Result.Err(OddError.TooOdd)
    return self / 2                     # bare success; wraps into Ok

fn use_it() i32 | OddError:
    let i32 four = 4
    let i32 half = four.half_checked()??   # the call yields Result@(i32, OddError)
    return Result.Ok(half)

fn main() i32:
    println("{use_it().realise(-1)}")
    return Result.Ok(0)

A channel method stops a chain until it is handled: b.checked().other() is CE2515, and b.checked()??.other() is the fix. Methods on the wrapper itself (.realise) stay legal.

A perk method takes the channel the same way, and the perk states it in the CONTRACT so every implementation answers the same error type:

enum SourceError:
    Closed

perk Source:
    fn read_one() i32 | SourceError

struct Counter:
    i32 value
    bool closed

extend Counter with Source:
    fn read_one() i32 | SourceError:
        if (self.closed):
            return Result.Err(SourceError.Closed)
        return self.value           # bare success; wraps into Ok

fn main() i32:
    let Counter c = Counter(42, false)
    println("{c.read_one().realise(0)}")
    return Result.Ok(0)

The contract and the implementation must agree. A contract that declares a channel and an implementation that omits it, an implementation that declares one the contract has not got, and two channels over different error types are all CE0133, which points at both ends.

Array extension targets: a concrete element extends one array type (extend i32[] sum()); a bare undeclared name binds a type parameter, so extend T[] applies to every element type. Anything else in the element position is CE2101.

Method-level type parameters: a method may declare its own @(U) after its name, solved from the arguments at each call:

extend i32 pick@(U)(U a, U b) U:
    if (self > 0):
        return a
    return b

fn main() i32:
    let i32 plus = 1
    println("{plus.pick(7, 9)}")     # U = i32, from the arguments
    return Result.Ok(0)

There is no call-site @(...) on a method, so every method-level parameter must be solvable from the arguments — a bare-param lambda cannot be (CE2063; annotate it: |i32 x| ...). A method-level name that repeats a receiver parameter is CE2064.

Generic Extension Methods:

struct Box@(T):
    T value

extend Box@(T) unwrap() T:
    return self.value

fn main() i32:
    let Box@(i32) b = Box(42)
    let i32 value = b.unwrap()  # Uses generic extension
    println("Unwrapped: {value}")
    return Result.Ok(0)

Extension methods can be generic over generic types, user-defined and built-in alike: the type parameter (T) is declared on the receiver type (Box@(T), List@(T)), and the compiler instantiates the method for each concrete type used in your program.

A concrete type argument in the target is a constraint. extend Box@(i32) extends Box@(i32) and nothing else, so one method name can serve several instantiations with a body written for each:

extend Box@(i32) tag() i32:
    return self.value * 10

extend Box@(string) tag() i32:
    return self.value.len()     # needs `use <collections/strings>`

Two rules come with that. A template and a concrete target for the same method name overlap, and Sushi rejects the overlap rather than picking the more specific one — there is no specialization:

extend Box@(T) tag() i32: ...
extend Box@(i32) tag() i32: ...    # error [CE0101]: duplicate function
                                   #   'extension method 'tag' for 'Box@(i32)''

And a partially concrete target is rejected too — name every type parameter, or make every argument concrete:

extend Pair@(i32, U) tag() i32: ...  # error [CE2098]: extension target 'Pair@(i32, U)'
                                     #   mixes concrete type arguments with type parameters

To give one instantiation its own behaviour where a template already covers it, implement a perk on the concrete target — a perk implementation outranks extension methods (see below).

Benefits: - Namespace organization: Group related functionality with the types they operate on - Discoverability: Methods appear natural on the type, making APIs easier to explore - Chainability: Method syntax enables fluent chaining: list.first().realise(0) - Zero cost: Compiles to the same code as a regular function call

You cannot override a built-in method: a method the compiler defines is always chosen before an extension method of the same name, so an extension that collides with one would be compiled and then never called. Sushi rejects it outright rather than letting it sit there looking like it works:

extend i32 hash() u64:      # error [CE2097]: extension method 'hash()' conflicts
    return 1 as u64         #                 with the built-in 'i32.hash()'

This covers every built-in family: the hash() and clone() the compiler derives for every struct and enum, the primitive and string methods (to_str, to_bits, len, trim, ...), the array methods, and the methods of Result, Maybe, Own, List and HashMap. Pick a different name, or use a perk.

Perks are the way to replace a built-in: a perk implementation takes precedence at every layer, by design.

perk Hashable:
    fn hash() u64

struct Point:
    i32 x
    i32 y

extend Point with Hashable:       # allowed -- this is the supported override
    fn hash() u64:
        return 999999 as u64

Standard Library Use: much of the Sushi standard library is exposed through this same method-call syntax. String methods like .len(), .find() and .split(), the collection methods on List@(T), and the array methods all reach you as receiver.method(...). They are built-in providers rather than ordinary extensions, though, which is why the names above are reserved -- your own extensions live alongside them, not on top of them.

The full precedence chain and its rationale are in docs/design/method-resolution.md.

Units and Imports

Each source file is a unit. use brings another unit's names into this one, and every use stands at the top of the file, above the first declaration:

use "helpers/geometry"      # another unit of this program
use <collections/hashmap>   # a standard-library module

By default an import puts the names it brings straight into this unit's scope, so you write them bare. Add as NAME and they go behind a dot instead:

use "helpers/geometry" as geo

fn main() i32:
    let f64 area = geo.circle_area(2.0).realise(0.0)
    println("{area} {geo.MAX_SIDES}")
    return Result.Ok(0)

Reach for the alias when two units disagree about a name, or when the reader of a call should be able to see where the name came from. The two forms compose: import one unit flat and another behind a dot, and each brings what it says and no more.

The dot works wherever a name is written, not only in front of a call. A type, a constructor, a match arm and a perk constraint all take one:

use "helpers/geometry" as geo

fn describe(geo.Vec v) string:
    match geo.Sign.Plus:
        geo.Sign.Plus -> return Result.Ok("north of {v.y}")
        geo.Sign.Minus -> return Result.Ok("south of {v.y}")

fn main() i32:
    let geo.Vec here = geo.Vec(1, 2)
    println("{describe(here).realise(\"nowhere\")}")
    return Result.Ok(0)

One place refuses it: a fixed array's size. i32[geo.SIZE] is an error, because the size is read while your file is being parsed and an alias is bound long after that. Declare the constant in your own unit and name it bare.

An alias behaves like any other name in the unit. A local variable of the same name shadows it, one name cannot hold two namespaces, and the alias is yours alone -- a unit that imports yours never sees it. Privacy is unchanged: geo.helper where helper is private to geometry is an error that says so, not one that says the name does not exist.

Two units may export one name and the program still builds. It only becomes a problem where you write that name bare with nothing to say which one you mean, and then the compiler stops at that line and lists the candidates. Your own unit's declaration always wins, so it is never the ambiguous one -- and that is also why use <math> no longer takes sin away from a unit that declares a sin of its own.

What an import reaches

Your unit sees what it declares and what its own use statements bring. Nothing else. An import is not passed on: if the unit you imported imported something in its turn, that something is its business and not yours. Write the use yourself and you have the name.

That holds for every kind of import. A standard-library module is in the scope of the file that wrote use <math>; the built-in HashMap is a name in the file that wrote use <collections/hashmap>; and an unsafe external block binds its namespace in the file that declares it. It also holds behind the dot: geo.circle_area reaches what geometry declares, never what geometry imported.

One consequence is worth knowing before it surprises you. A function you can call may return a type you cannot write:

# geometry.sushi        # shapes.sushi            # main.sushi
public struct Vec:      use "geometry"            use "shapes"
    f64 x               public fn origin() Vec:   fn main() i32:
                            return Result.Ok(...)     let Vec v = origin()??

main may call origin(), because origin is in scope. It may not write Vec until it imports geometry too -- and it cannot avoid writing it, because a let needs a type. So the rule is short: to name a type, import the unit that declares it. The compiler says which import is missing.

The unit in the middle can spare its importers that line. public use re-exports: it takes what an import brings and hands it on as the unit's own.

# geometry.sushi        # shapes.sushi                  # main.sushi
public struct Vec:      public use "geometry"          use "shapes"
    f64 x               public fn origin() Vec:        fn main() i32:
                            return Result.Ok(...)          let Vec v = origin()??

Now shapes says "whoever imports me gets geometry too", and main writes Vec with one import; use "shapes" as sh gives sh.Vec beside sh.origin. Only a public use does this -- a plain use stays private to the unit that wrote it -- and only the public names travel. A re-exported name behaves like any imported one: your own declaration wins over it, and two re-exports that offer different declarations of one name stop at the bare use with the candidates listed. The standard library leans on it: use <io/fs> alone brings IoError, because the module whose calls answer it hands the name on.

Memory Management

Sushi provides memory safety without garbage collection through a combination of RAII (Resource Acquisition Is Initialization), compile-time borrow checking, and move semantics. These features work together to prevent common memory bugs while maintaining C-like performance.

Borrowing, aka References

A parameter never takes ownership unless it says nom, so a plain f(x) already leaves x yours. peek and poke go further: they pass a pointer instead of the value.

  • peek T - Read-only borrow by pointer (multiple allowed simultaneously)
  • poke T - Read-write borrow by pointer (exclusive access), so the callee's writes reach the caller's value

Reach for them when the callee must write back (poke), or when copying the value would be expensive — a large fixed array or plain struct.

Sushi's borrow checker ensures that references are always valid and that aliasing rules are enforced at compile time:

fn increment(poke i32 counter) ~:
    counter := counter + 1
    return Result.Ok(~)

fn display(peek i32 value) ~:
    println("Value: {value}")
    return Result.Ok(~)

fn main() i32:
    let i32 count = 0
    increment(poke count)
    increment(poke count)
    display(peek count)  # Read-only access
    println("Count: {count}")  # Prints: Count: 2

    return Result.Ok(0)

Borrowing Rules: - Multiple peek allowed: You can have multiple read-only borrows of the same variable simultaneously - One poke at a time: Mutable borrows require exclusive access - Cannot mix modes: Cannot have peek and poke borrows of the same variable at the same time - poke coerces to peek: A mutable reference can be passed where a read-only reference is expected - References must be valid: The borrow checker ensures references never outlive the data they point to - Zero cost: References compile to simple pointers with no runtime overhead

What references allow: - Reading function arguments without copying (use peek) - Modifying function arguments in-place without copying large structures (use poke) - Building efficient data structures that reference existing data - Avoiding expensive clones when you just need to read or modify a value

Example - avoiding copies:

struct LargeData:
    i32[1000] values

fn process(poke LargeData data) ~:
    # Can access and modify data.values without copying 4000 bytes
    data.values[0] := 42
    return Result.Ok(~)

fn read_only(peek LargeData data) i32:
    # Read-only access - cannot modify
    return Result.Ok(data.values[0])

The borrow checker runs at compile time (the borrow pass of the semantic analysis pipeline), so there's no runtime cost to these safety guarantees.

RAII (Automatic Cleanup)

RAII (Resource Acquisition Is Initialization) is Sushi's core memory management strategy. When a value goes out of scope, its destructor automatically runs, freeing any resources it owns. This applies recursively to nested structures:

struct Buffer:
    string[] lines

fn process() ~:
    let Buffer buf = Buffer(lines: from([]))
    buf.lines.push("Line 1")
    buf.lines.push("Line 2")
    # buf and buf.lines automatically destroyed when function returns
    return Result.Ok(~)

fn main() i32:
    process()
    return Result.Ok(0)

How RAII works in Sushi: 1. When a variable goes out of scope (end of function, end of block, error propagation with ??), its destructor runs 2. For structs, the destructor recursively destroys each field 3. For arrays, the destructor recursively destroys each element 4. For enums, the destructor examines the discriminant and destroys the active variant's data 5. Primitives and strings require no cleanup

The Recursive Destructor: Sushi's backend implements a general emit_value_destructor() function that handles cleanup for all types. It recursively traverses your data structures: - Structs with arrays: The array fields are freed, then the struct itself - Arrays of structs: Each struct is destroyed, then the array storage is freed - Enums with complex data: The discriminant determines which variant is active, then that variant's data is cleaned up - Nested collections: List@(HashMap@(string, List@(i32))) correctly frees all levels

Integration with error handling: RAII is crucial for the ?? operator. When an error is propagated, all variables in the current scope are destroyed before returning, preventing resource leaks even in error paths.

Benefits: - No manual memory management - no free() calls needed (except for explicit .free() on collections when you want early cleanup) - No memory leaks - resources are always freed when they go out of scope - Exception safety - errors can't cause resource leaks - Deterministic cleanup - you know exactly when destructors run

Own@(T) for Heap Allocation

Own@(T) is Sushi's type for heap-allocated, owned values. It's essential for building recursive data structures like linked lists and trees, where a struct needs to contain an optional instance of itself:

struct Point:
    i32 x
    i32 y

# Recursive struct: the optional next pointer is Maybe@(Own@(Node))
struct Node:
    i32 value
    Maybe@(Own@(Node)) next

fn main() i32:
    # Allocate a Point on the heap, then dereference it
    let Own@(Point) ptr = Own.alloc(Point(10, 20))
    let Point loaded = ptr.get()
    println("Point: ({loaded.x}, {loaded.y})")

    # A linked-list node; next is an optional owned pointer
    let Node head = Node(1, Maybe.None())
    println("Head: {head.value}")

    if (head.next.is_some()):
        println("List has more nodes")
    else:
        println("End of list")

    return Result.Ok(0)

Why Own@(T) exists: Without Own@(T), you cannot create recursive types because the compiler needs to know the size of every struct at compile time. A Node containing another Node would have infinite size. Own@(T) breaks the cycle by storing a pointer (fixed size) to heap-allocated data.

Own@(T) methods: - Own.alloc(value) - Allocates value on the heap and returns an Own@(T) - .get() - Returns the contained value - .destroy() - Explicitly frees the heap memory (RAII calls this automatically)

Own@(T) itself is always populated. To model an optional owned pointer (such as the next field of a list node), wrap it in Maybe@(Own@(T)) and use Maybe.Some(...) / Maybe.None() together with .is_some() / .is_none().

Memory management with Own@(T): Own@(T) integrates with RAII - when an Own@(T) goes out of scope, it automatically calls .destroy() on the contained value, freeing the heap memory. This means recursive structures are properly cleaned up when they go out of scope, even if they're deeply nested.

Common patterns: - Linked lists: Each node owns the next node - Trees: Each node owns its children - Optional heap data: Use Own@(T) instead of nullable pointers for optional heap-allocated data

Files and Handles

A file is a value. open() answers a File that OWNS its operating-system descriptor: it moves to exactly one owner, and when that owner leaves scope the descriptor closes. There is nothing to remember and nothing to pair.

use <io/fs>

fn log_it(string message) ~ | IoError:
    let File f = open("out.log", FileMode.Append())??
    f.writeln(message)??
    return Result.Ok(~)
    # f drops here, and the descriptor closes.

fn main() i32:
    match log_it("Mostly Harmless"):
        Result.Ok(_) -> println("logged")
        Result.Err(_) -> println("could not log")
    return Result.Ok(0)

Three rules follow from the ownership, and each one is a compile error rather than a run-time surprise:

  • A handle cannot be copied. .clone() on a File is CE2431: a field-by-field copy would duplicate the descriptor number and leave two owners that both close it. .share() is the operation that means "a second handle", and it says so: it is dup(2), so both handles sit over one open file description and one offset. For concurrent reads of one file, read_at() and write_at() take the offset as an argument and share nothing.
  • close() CONSUMES the handle. Use it only where the failure has to be SEEN -- a destructor cannot answer a Result. A read after a close is CE2435 while compiling.
  • The channel is IoError from the open to the last read, so one ?? chain covers the whole function with no conversion in the middle.

What a handle can do

stdin, stdout and stderr are File unit variables (public var, storage with an address) over descriptors 0, 1 and 2, so a function that takes a poke File takes either a file or the console. Better still, a function can name the CAPABILITY it needs instead of the type. <io/contracts> declares three perks -- Reader, Writer and Seek -- whose methods take poke self, and a TcpStream or a BufWriter@(File) satisfies the first two just as a File does:

use <io/fs>

fn emit@(W: Writer)(poke W dst, string line) ~ | IoError:
    dst.write(line)??
    dst.flush()??
    return Result.Ok(~)

fn main() i32:
    match emit(poke stdout, "Mostly Harmless\n"):
        Result.Ok(_) -> return Result.Ok(0)
        Result.Err(_) -> return Result.Ok(1)

Buffering is a type you opt into

A handle does NOT buffer: every read and every write is one system call. That keeps the model honest -- there is no hidden buffer to flush at a point nobody wrote, and no destructor swallowing the failure of a flush the program never asked for.

When a loop makes that cost matter, wrap the handle. <io/buf> gives BufReader@(R) over any Reader and BufWriter@(W) over any Writer, each reading or writing one WINDOW per system call. The wrapper TAKES the handle (nom), so there is never a moment where both the handle and its buffer are usable.

r.lines() then takes the reader in turn and answers a line iterator that foreach walks. The ?? on the binder is the short form: the item is an ordinary Result@(string, IoError), and the marker leaves the function on the first read failure exactly as ?? does anywhere else. Drop it and the body gets the Result itself, which is what lets a loop report one bad line and carry on.

use <io/fs>
use <io/buf>

fn number_lines(string path) ~ | IoError:
    let File f = open(path, FileMode.Read())??
    let BufReader@(File) r = BufReader.new(nom f, 8192)??

    let i32 n = 0
    foreach(line?? in r.lines()):
        n := n + 1
        println("{n}: {line}")

    return Result.Ok(~)
    # The iterator drops here: it destroys the reader, which closes the descriptor.

fn main() i32:
    match number_lines("large.txt"):
        Result.Ok(_) -> return Result.Ok(0)
        Result.Err(_) -> return Result.Ok(1)

A BufWriter@(W) flushes when it drops, but a drop cannot report a failure. finish() is the checked drain, and it CONSUMES the writer -- so once you have called it, nothing can forget to flush afterwards:

use <io/fs>
use <io/buf>

fn write_report(string path, i32 count) ~ | IoError:
    let File f = open(path, FileMode.Write())??
    let BufWriter@(File) w = BufWriter.new(nom f, 8192)??
    w.write_line("Report")??
    w.write_line("Items processed: {count}")??
    w.finish()??               # the ONE drain, and its failure is seen
    return Result.Ok(~)

fn main() i32:
    match write_report("report.txt", 42):
        Result.Ok(_) -> println("written")
        Result.Err(_) -> println("could not write it")
    return Result.Ok(0)

into_inner() is the way back out: it flushes, then hands the handle over.

Next Steps

This guide covered the basics. For more details:


Previous: Getting Started | Next: Language Reference